@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.
@@ -10,6 +10,127 @@
10
10
  <int64 name="SourceAssetId">-1</int64>
11
11
  <BinaryString name="Tags"></BinaryString>
12
12
  </Properties>
13
+ <Item class="ModuleScript" referent="RBX_animate_character">
14
+ <Properties>
15
+ <BinaryString name="AttributesSerialize"></BinaryString>
16
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
17
+ <bool name="DefinesCapabilities">false</bool>
18
+ <bool name="Disabled">false</bool>
19
+ <Content name="LinkedSource"><null></null></Content>
20
+ <string name="Name">animate_character</string>
21
+ <int64 name="SourceAssetId">-1</int64>
22
+ <BinaryString name="Tags"></BinaryString>
23
+ <ProtectedString name="Source"><![CDATA[--!strict
24
+ -- ══════════════════════════════════════════════════════════════════════
25
+ -- Skill Module: animate_character
26
+ -- Deskripsi: Semantic Tool untuk AI Agent membuat animasi karakter halus
27
+ -- Menggunakan NV-Animation Engine (Walk, Slash, Punch, Idle, atau Custom)
28
+ -- ══════════════════════════════════════════════════════════════════════
29
+
30
+ local HttpService = game:GetService("HttpService")
31
+ local dispatcherMod = script.Parent:WaitForChild("nv_anim_dispatcher")
32
+ local Dispatcher = require(dispatcherMod)
33
+
34
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
35
+ if not targetInstance then
36
+ return { status = "error", error = "Target karakter rig untuk animate_character tidak ditemukan: " .. tostring(targetPath) }
37
+ end
38
+
39
+ local payload: { [string]: any } = {}
40
+ local ok, err = pcall(function()
41
+ if type(data) == "table" then
42
+ payload = data
43
+ else
44
+ payload = HttpService:JSONDecode(tostring(data))
45
+ end
46
+ end)
47
+
48
+ if not ok or type(payload) ~= "table" then
49
+ return { status = "error", error = "Format payload animasi tidak valid: " .. tostring(err) }
50
+ end
51
+
52
+ local action = tostring(payload.action or "walk_cycle")
53
+ local parameters = payload.parameters or {}
54
+ local keyPoses = payload.key_poses
55
+
56
+ return Dispatcher.createAnimation(targetInstance, action, parameters, keyPoses)
57
+ end
58
+
59
+ ]]></ProtectedString>
60
+ </Properties>
61
+ </Item>
62
+ <Item class="ModuleScript" referent="RBX_apply_vfx">
63
+ <Properties>
64
+ <BinaryString name="AttributesSerialize"></BinaryString>
65
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
66
+ <bool name="DefinesCapabilities">false</bool>
67
+ <bool name="Disabled">false</bool>
68
+ <Content name="LinkedSource"><null></null></Content>
69
+ <string name="Name">apply_vfx</string>
70
+ <int64 name="SourceAssetId">-1</int64>
71
+ <BinaryString name="Tags"></BinaryString>
72
+ <ProtectedString name="Source"><![CDATA[--!strict
73
+ -- ══════════════════════════════════════════════════════════════════════
74
+ -- Skill Module: apply_vfx
75
+ -- Deskripsi: Menerapkan tabel data VFX murni ke instance target di Studio
76
+ -- ══════════════════════════════════════════════════════════════════════
77
+
78
+ local HttpService = game:GetService("HttpService")
79
+ local serializerMod = script.Parent:WaitForChild("nv_vfx_serializer")
80
+ local Serializer = require(serializerMod)
81
+
82
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
83
+ if not targetInstance then
84
+ return { status = "error", error = "Target instance untuk apply_vfx tidak ditemukan: " .. tostring(targetPath) }
85
+ end
86
+
87
+ local payload = {}
88
+ local ok, err = pcall(function()
89
+ if type(data) == "table" then
90
+ payload = data
91
+ else
92
+ payload = HttpService:JSONDecode(tostring(data))
93
+ end
94
+ end)
95
+
96
+ if not ok or type(payload) ~= "table" then
97
+ return { status = "error", error = "Format payload VFX tidak valid: " .. tostring(err) }
98
+ end
99
+
100
+ -- Extract treeData (bisa di payload.data atau langsung di payload)
101
+ local treeData = payload.data or payload
102
+ if not treeData.nodes then
103
+ return { status = "error", error = "Payload tidak memiliki struktur 'nodes' yang valid untuk deserialisasi." }
104
+ end
105
+
106
+ local scale = tonumber(payload.scale) or 1
107
+ local enabled = payload.enabled
108
+
109
+ local desResult = Serializer.deserialize(treeData, targetInstance, {
110
+ scale = scale,
111
+ enabled = enabled
112
+ })
113
+
114
+ if desResult.status == "error" then
115
+ return {
116
+ status = "error",
117
+ error = "Gagal merekonstruksi VFX: " .. table.concat(desResult.errors or {}, "; ")
118
+ }
119
+ end
120
+
121
+ return {
122
+ status = "success",
123
+ result = {
124
+ message = string.format("Berhasil menerapkan VFX '%s' (%d node) ke '%s'", tostring(treeData.rootName or "VFX"), desResult.createdCount, targetInstance:GetFullName()),
125
+ createdCount = desResult.createdCount,
126
+ errors = desResult.errors
127
+ }
128
+ }
129
+ end
130
+
131
+ ]]></ProtectedString>
132
+ </Properties>
133
+ </Item>
13
134
  <Item class="ModuleScript" referent="RBX_batch_create_instances">
14
135
  <Properties>
15
136
  <BinaryString name="AttributesSerialize"></BinaryString>
@@ -330,6 +451,54 @@ end
330
451
  return { status = "error", error = "Gagal menghapus instance: " .. tostring(err) }
331
452
  end
332
453
  end
454
+ ]]></ProtectedString>
455
+ </Properties>
456
+ </Item>
457
+ <Item class="ModuleScript" referent="RBX_edit_rig">
458
+ <Properties>
459
+ <BinaryString name="AttributesSerialize"></BinaryString>
460
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
461
+ <bool name="DefinesCapabilities">false</bool>
462
+ <bool name="Disabled">false</bool>
463
+ <Content name="LinkedSource"><null></null></Content>
464
+ <string name="Name">edit_rig</string>
465
+ <int64 name="SourceAssetId">-1</int64>
466
+ <BinaryString name="Tags"></BinaryString>
467
+ <ProtectedString name="Source"><![CDATA[--!strict
468
+ -- ══════════════════════════════════════════════════════════════════════
469
+ -- Skill Module: edit_rig
470
+ -- Deskripsi: Semantic Tool untuk AI Agent mengelola rig, Motor6D, Weld,
471
+ -- C0/C1 pivot compensation, auto-rigging, dan pengelasan model.
472
+ -- ══════════════════════════════════════════════════════════════════════
473
+
474
+ local HttpService = game:GetService("HttpService")
475
+ local dispatcherMod = script.Parent:WaitForChild("nv_rig_dispatcher")
476
+ local Dispatcher = require(dispatcherMod)
477
+
478
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
479
+ if not targetInstance then
480
+ return { status = "error", error = "Target instance untuk edit_rig tidak ditemukan: " .. tostring(targetPath) }
481
+ end
482
+
483
+ local payload: { [string]: any } = {}
484
+ local ok, err = pcall(function()
485
+ if type(data) == "table" then
486
+ payload = data
487
+ else
488
+ payload = HttpService:JSONDecode(tostring(data))
489
+ end
490
+ end)
491
+
492
+ if not ok or type(payload) ~= "table" then
493
+ return { status = "error", error = "Format payload edit_rig tidak valid: " .. tostring(err) }
494
+ end
495
+
496
+ local action = tostring(payload.action or "inspect")
497
+ local parameters = payload.parameters or payload
498
+
499
+ return Dispatcher.dispatch(targetInstance, action, parameters)
500
+ end
501
+
333
502
  ]]></ProtectedString>
334
503
  </Properties>
335
504
  </Item>
@@ -807,7 +976,7 @@ end
807
976
  <BinaryString name="Tags"></BinaryString>
808
977
  <token name="RunContext">0</token>
809
978
  <ProtectedString name="Source"><![CDATA[-- ══════════════════════════════════════════════════════════════════════
810
- -- NVStudio MCP Plugin v2.3.0
979
+ -- NVStudio MCP Plugin v2.4.1
811
980
  -- Arsitektur: Dual-Mode WebSocket Duplex (Port 3055/ws) + Fallback Long-Polling
812
981
  -- Plugin auto-connect saat dimuat via WebSocket native, dengan fallback otomatis
813
982
  -- ke HTTP stream jika WebSocket tidak tersedia di environment Roblox Studio ini.
@@ -1616,154 +1785,2496 @@ end)
1616
1785
  ]]></ProtectedString>
1617
1786
  </Properties>
1618
1787
  </Item>
1619
- <Item class="ModuleScript" referent="RBX_patch_script_source">
1788
+ <Item class="ModuleScript" referent="RBX_nv_anim_dispatcher">
1620
1789
  <Properties>
1621
1790
  <BinaryString name="AttributesSerialize"></BinaryString>
1622
1791
  <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
1623
1792
  <bool name="DefinesCapabilities">false</bool>
1624
1793
  <bool name="Disabled">false</bool>
1625
1794
  <Content name="LinkedSource"><null></null></Content>
1626
- <string name="Name">patch_script_source</string>
1795
+ <string name="Name">nv_anim_dispatcher</string>
1627
1796
  <int64 name="SourceAssetId">-1</int64>
1628
1797
  <BinaryString name="Tags"></BinaryString>
1629
- <ProtectedString name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
1798
+ <ProtectedString name="Source"><![CDATA[--!strict
1799
+ -- ══════════════════════════════════════════════════════════════════════
1800
+ -- NVStudio Subsystem: nv_anim_dispatcher
1801
+ -- Deskripsi: Headless Modular Command Dispatcher untuk Pembuatan Animasi
1802
+ -- Mengatur inisialisasi rig, analytical IK, generasi keyframe kurva halus,
1803
+ -- dan ekspor resmi ke KeyframeSequence di Roblox Studio.
1804
+ -- ══════════════════════════════════════════════════════════════════════
1630
1805
 
1631
- return function(targetInstance, data, ctx, targetPath)
1632
- if not targetInstance:IsA("LuaSourceContainer") then
1633
- return { status = "error", error = "Target BUKAN Script (ClassName: " .. targetInstance.ClassName .. ")." }
1634
- end
1806
+ local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
1807
+ local Kinematics = require(kinematicsMod)
1808
+ local curvesMod = script.Parent:WaitForChild("nv_curves")
1809
+ local Curves = require(curvesMod)
1810
+ local motionMod = script.Parent:WaitForChild("nv_motion_generator")
1811
+ local Motion = require(motionMod)
1812
+
1813
+ local Dispatcher = {}
1814
+
1815
+ -- ── 1. Helper: Temukan Rig dari Instance atau Path ────────────────────────────
1816
+ function Dispatcher.getRigInfo(targetInstance: Instance?): Kinematics.RigInfo?
1817
+ if not targetInstance then return nil end
1818
+ local model = targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model")
1819
+ if not model then return nil end
1820
+ return Kinematics.inspectRig(model)
1821
+ end
1635
1822
 
1636
- local payload = nil
1637
- pcall(function()
1638
- payload = HttpService:JSONDecode(data)
1639
- end)
1823
+ -- ── 2. Buat KeyframeSequence Resmi di Roblox ─────────────────────────────────
1824
+ function Dispatcher.buildKeyframeSequence(
1825
+ rigInfo: Kinematics.RigInfo,
1826
+ clipName: string,
1827
+ keyframes: { Motion.KeyframeData },
1828
+ options: { loop: boolean?, priority: Enum.AnimationPriority? }?
1829
+ ): KeyframeSequence
1830
+ local opts = options or {}
1831
+ local isR15 = rigInfo.isR15
1832
+
1833
+ local seq = Instance.new("KeyframeSequence")
1834
+ seq.Name = clipName
1835
+ seq.Loop = opts.loop ~= false
1836
+ seq.Priority = opts.priority or Enum.AnimationPriority.Action
1837
+
1838
+ -- Urutkan keyframes berdasarkan waktu
1839
+ table.sort(keyframes, function(a, b) return a.time < b.time end)
1840
+
1841
+ for _, kfData in ipairs(keyframes) do
1842
+ local kf = Instance.new("Keyframe")
1843
+ kf.Time = kfData.time
1844
+ kf.Name = string.format("Frame_%.2f", kfData.time)
1845
+
1846
+ -- Root Pose (HumanoidRootPart)
1847
+ local rootPose = Instance.new("Pose")
1848
+ rootPose.Name = "HumanoidRootPart"
1849
+ rootPose.Weight = 1
1850
+ rootPose.EasingStyle = Enum.PoseEasingStyle.Cubic
1851
+ rootPose.EasingDirection = Enum.PoseEasingDirection.InOut
1852
+
1853
+ local function createPose(name: string, cf: CFrame?, parent: Instance?): Pose
1854
+ local p = Instance.new("Pose")
1855
+ p.Name = name
1856
+ p.Weight = 1
1857
+ p.EasingStyle = Enum.PoseEasingStyle.Cubic
1858
+ p.EasingDirection = Enum.PoseEasingDirection.InOut
1859
+ if cf then p.CFrame = cf end
1860
+ if parent then p.Parent = parent end
1861
+ return p
1862
+ end
1640
1863
 
1641
- if not payload then
1642
- return { status = "error", error = "Payload data harus berupa JSON valid." }
1643
- end
1864
+ if isR15 then
1865
+ -- LowerTorso pose
1866
+ local lowerTorsoPose = createPose("LowerTorso", kfData.poses["Root"], nil)
1644
1867
 
1645
- local currentSource = targetInstance.Source
1646
- local newSource = currentSource
1647
- local affectedLines = 0
1868
+ -- UpperTorso pose
1869
+ local upperTorsoPose = createPose("UpperTorso", kfData.poses["Waist"], nil)
1648
1870
 
1649
- -- Mode 1: Search & Replace Exact Chunk
1650
- if payload.target_content and payload.replacement_content then
1651
- local targetContent = payload.target_content
1652
- local replacementContent = payload.replacement_content
1653
- local allowMultiple = payload.allow_multiple or false
1871
+ -- Head
1872
+ if kfData.poses["Neck"] then
1873
+ createPose("Head", kfData.poses["Neck"], upperTorsoPose)
1874
+ end
1654
1875
 
1655
- if targetContent == "" then
1656
- return { status = "error", error = "target_content tidak boleh berupa string kosong." }
1657
- end
1876
+ -- Arms
1877
+ for _, side in ipairs({ "Left", "Right" }) do
1878
+ local shoulderPose = createPose(side .. "UpperArm", kfData.poses[side .. "Shoulder"], nil)
1879
+ local elbowPose = shoulderPose
1880
+ if kfData.poses[side .. "Elbow"] then
1881
+ elbowPose = createPose(side .. "LowerArm", kfData.poses[side .. "Elbow"], shoulderPose)
1882
+ end
1883
+ if kfData.poses[side .. "Wrist"] then
1884
+ createPose(side .. "Hand", kfData.poses[side .. "Wrist"], elbowPose)
1885
+ end
1886
+ shoulderPose.Parent = upperTorsoPose
1887
+ end
1658
1888
 
1659
- -- Escape magic characters for string.gsub if needed, or use string.find manually.
1660
- -- string.gsub doesn't do plain text replacement directly without escaping.
1661
- -- We'll write a plain text replace function.
1662
-
1663
- local function plainReplace(str, pattern, repl, max)
1664
- local res = ""
1665
- local startIdx = 1
1666
- local count = 0
1667
- while true do
1668
- if max and count >= max then break end
1669
- local findStart, findEnd = string.find(str, pattern, startIdx, true) -- true for plain match
1670
- if not findStart then break end
1671
-
1672
- res = res .. string.sub(str, startIdx, findStart - 1) .. repl
1673
- startIdx = findEnd + 1
1674
- count = count + 1
1675
- end
1676
- res = res .. string.sub(str, startIdx)
1677
- return res, count
1678
- end
1889
+ -- Legs
1890
+ for _, side in ipairs({ "Left", "Right" }) do
1891
+ local hipPose = createPose(side .. "UpperLeg", kfData.poses[side .. "Hip"], nil)
1892
+ local kneePose = hipPose
1893
+ if kfData.poses[side .. "Knee"] then
1894
+ kneePose = createPose(side .. "LowerLeg", kfData.poses[side .. "Knee"], hipPose)
1895
+ end
1896
+ if kfData.poses[side .. "Ankle"] then
1897
+ createPose(side .. "Foot", kfData.poses[side .. "Ankle"], kneePose)
1898
+ end
1899
+ hipPose.Parent = lowerTorsoPose
1900
+ end
1679
1901
 
1680
- local function countOccurrences(str, pattern)
1681
- local count = 0
1682
- local startIdx = 1
1683
- while true do
1684
- local findStart, findEnd = string.find(str, pattern, startIdx, true)
1685
- if not findStart then break end
1686
- count = count + 1
1687
- startIdx = findEnd + 1
1688
- if count > 1 and not allowMultiple then break end
1689
- end
1690
- return count
1691
- end
1902
+ upperTorsoPose.Parent = lowerTorsoPose
1903
+ lowerTorsoPose.Parent = rootPose
1904
+ else
1905
+ -- R6 Pose Structure
1906
+ local torsoPose = createPose("Torso", kfData.poses["RootJoint"], nil)
1692
1907
 
1693
- local occurrences = countOccurrences(currentSource, targetContent)
1694
-
1695
- if occurrences == 0 then
1696
- return { status = "error", error = "target_content tidak ditemukan di dalam script." }
1697
- elseif occurrences > 1 and not allowMultiple then
1698
- return { status = "error", error = "target_content ditemukan lebih dari 1 kali. Tolong sertakan baris konteks lebih banyak agar unik, atau set allow_multiple = true." }
1699
- end
1908
+ if kfData.poses["Neck"] then
1909
+ createPose("Head", kfData.poses["Neck"], torsoPose)
1910
+ end
1700
1911
 
1701
- newSource, affectedLines = plainReplace(currentSource, targetContent, replacementContent)
1702
-
1703
- -- Mode 2: Line Range Replace
1704
- elseif payload.start_line and payload.end_line and payload.replacement_content then
1705
- local lines = string.split(currentSource, "\n")
1706
- local startLine = tonumber(payload.start_line)
1707
- local endLine = tonumber(payload.end_line)
1708
-
1709
- if not startLine or not endLine or startLine < 1 or endLine > #lines or startLine > endLine then
1710
- return { status = "error", error = "Range baris tidak valid. Jumlah baris script: " .. #lines }
1711
- end
1912
+ for _, limb in ipairs({ "Left Shoulder", "Right Shoulder", "Left Hip", "Right Hip" }) do
1913
+ if kfData.poses[limb] then
1914
+ local partName = limb == "Left Shoulder" and "Left Arm"
1915
+ or limb == "Right Shoulder" and "Right Arm"
1916
+ or limb == "Left Hip" and "Left Leg"
1917
+ or "Right Leg"
1918
+ createPose(partName, kfData.poses[limb], torsoPose)
1919
+ end
1920
+ end
1712
1921
 
1713
- local newLines = {}
1714
- for i = 1, startLine - 1 do
1715
- table.insert(newLines, lines[i])
1716
- end
1717
-
1718
- table.insert(newLines, payload.replacement_content)
1719
-
1720
- for i = endLine + 1, #lines do
1721
- table.insert(newLines, lines[i])
1722
- end
1723
-
1724
- newSource = table.concat(newLines, "\n")
1725
- affectedLines = (endLine - startLine) + 1
1726
- else
1727
- return { status = "error", error = "Payload tidak lengkap. Butuh (target_content & replacement_content) atau (start_line, end_line & replacement_content)." }
1728
- end
1922
+ torsoPose.Parent = rootPose
1923
+ end
1729
1924
 
1730
- -- Simpan backup snapshot sebelum mengaplikasikan patch
1731
- if type(ctx.scriptHistory[targetPath]) ~= "table" then
1732
- ctx.scriptHistory[targetPath] = {}
1733
- end
1734
- table.insert(ctx.scriptHistory[targetPath], currentSource)
1735
- if #ctx.scriptHistory[targetPath] > 15 then table.remove(ctx.scriptHistory[targetPath], 1) end
1925
+ -- Lampirkan VFX KeyframeMarkers jika ada event di frame ini
1926
+ if opts.vfx_events and type(opts.vfx_events) == "table" then
1927
+ for _, evt in ipairs(opts.vfx_events) do
1928
+ local evtTime = tonumber(evt.time) or 0
1929
+ if math.abs(evtTime - kfData.time) <= 0.08 then
1930
+ local marker = Instance.new("KeyframeMarker")
1931
+ marker.Name = evt.marker_name or "VFX"
1932
+ marker.Value = evt.value or evt.action or "Emit"
1933
+ marker.Parent = kf
1934
+ end
1935
+ end
1936
+ end
1736
1937
 
1737
- -- Terapkan perubahan
1738
- targetInstance.Source = newSource
1739
-
1740
- return {
1741
- status = "success",
1742
- result = "Patch berhasil diterapkan pada '" .. targetInstance.Name .. "'. Perubahan diaplikasikan.",
1743
- affected_lines = affectedLines
1744
- }
1938
+ rootPose.Parent = kf
1939
+ kf.Parent = seq
1940
+ end
1941
+
1942
+ return seq
1745
1943
  end
1944
+
1945
+ -- ── 3. Eksekusi Perintah Pembuatan Animasi ────────────────────────────────────
1946
+ function Dispatcher.createAnimation(
1947
+ targetInstance: Instance,
1948
+ action: string,
1949
+ parameters: { [string]: any }?,
1950
+ keyPoses: { any }?
1951
+ ): { [string]: any }
1952
+ local rigInfo = Dispatcher.getRigInfo(targetInstance)
1953
+ if not rigInfo then
1954
+ return { status = "error", error = "Target bukan model karakter rig (R6/R15) yang valid: " .. targetInstance:GetFullName() }
1955
+ end
1956
+
1957
+ local params = parameters or {}
1958
+ local clipName = params.clipName or (action .. "_" .. (rigInfo.isR15 and "R15" or "R6"))
1959
+ local keyframes: { Motion.KeyframeData }
1960
+
1961
+ if action == "walk_cycle" or action == "walk" then
1962
+ keyframes = Motion.generateWalkCycle(rigInfo, params)
1963
+ elseif action == "melee_slash" or action == "slash" or action == "sword" then
1964
+ keyframes = Motion.generateMeleeSlash(rigInfo, params)
1965
+ elseif action == "punch" or action == "combat_punch" then
1966
+ keyframes = Motion.generatePunch(rigInfo, params)
1967
+ elseif action == "idle" or action == "idle_breath" then
1968
+ keyframes = Motion.generateIdle(rigInfo, params)
1969
+ elseif action == "custom" and keyPoses and #keyPoses > 0 then
1970
+ -- Parser custom keyposes dari input agent
1971
+ keyframes = {}
1972
+ for _, kp in ipairs(keyPoses) do
1973
+ local motorCfs: { [string]: CFrame } = {}
1974
+ if kp.poses then
1975
+ for mName, cVal in pairs(kp.poses) do
1976
+ if type(cVal) == "table" and cVal.t == "CFrame" then
1977
+ motorCfs[mName] = CFrame.new(table.unpack(cVal.v))
1978
+ elseif type(cVal) == "table" and cVal.angles then
1979
+ local a = cVal.angles
1980
+ motorCfs[mName] = CFrame.Angles(math.rad(a[1] or 0), math.rad(a[2] or 0), math.rad(a[3] or 0))
1981
+ end
1982
+ end
1983
+ end
1984
+ table.insert(keyframes, {
1985
+ time = tonumber(kp.time) or 0,
1986
+ poses = motorCfs
1987
+ })
1988
+ end
1989
+ else
1990
+ return {
1991
+ status = "error",
1992
+ error = "Action tidak dikenal: '" .. tostring(action) .. "'. Pilihan: walk_cycle, melee_slash, punch, idle_breath, custom."
1993
+ }
1994
+ end
1995
+
1996
+ -- Simpan KeyframeSequence ke AnimSaves pada Rig
1997
+ local animSaves = rigInfo.model:FindFirstChild("AnimSaves")
1998
+ if not animSaves then
1999
+ animSaves = Instance.new("Model")
2000
+ animSaves.Name = "AnimSaves"
2001
+ animSaves.Parent = rigInfo.model
2002
+ end
2003
+
2004
+ local kfSeq = Dispatcher.buildKeyframeSequence(rigInfo, clipName, keyframes, {
2005
+ loop = params.loop,
2006
+ priority = Enum.AnimationPriority.Action,
2007
+ vfx_events = params.vfx_events
2008
+ })
2009
+ kfSeq.Parent = animSaves
2010
+
2011
+ -- Terapkan pose pertama ke karakter di Studio untuk live feedback
2012
+ if keyframes[1] and keyframes[1].poses then
2013
+ for mName, cf in pairs(keyframes[1].poses) do
2014
+ local m = rigInfo.motors[mName]
2015
+ if m then
2016
+ pcall(function() m.C0 = cf end)
2017
+ end
2018
+ end
2019
+ end
2020
+
2021
+ return {
2022
+ status = "success",
2023
+ result = {
2024
+ clipName = clipName,
2025
+ action = action,
2026
+ rigType = rigInfo.isR15 and "R15" or "R6",
2027
+ keyframeCount = #keyframes,
2028
+ duration = keyframes[#keyframes].time,
2029
+ savedPath = kfSeq:GetFullName(),
2030
+ message = string.format("Animasi '%s' berhasil dibuat dan disimpan ke '%s'", clipName, kfSeq:GetFullName())
2031
+ }
2032
+ }
2033
+ end
2034
+
2035
+ return Dispatcher
2036
+
1746
2037
  ]]></ProtectedString>
1747
2038
  </Properties>
1748
2039
  </Item>
1749
- <Item class="ModuleScript" referent="RBX_preview_asset">
2040
+ <Item class="ModuleScript" referent="RBX_nv_auto_rigger">
1750
2041
  <Properties>
1751
2042
  <BinaryString name="AttributesSerialize"></BinaryString>
1752
2043
  <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
1753
2044
  <bool name="DefinesCapabilities">false</bool>
1754
2045
  <bool name="Disabled">false</bool>
1755
2046
  <Content name="LinkedSource"><null></null></Content>
1756
- <string name="Name">preview_asset</string>
2047
+ <string name="Name">nv_auto_rigger</string>
1757
2048
  <int64 name="SourceAssetId">-1</int64>
1758
2049
  <BinaryString name="Tags"></BinaryString>
1759
2050
  <ProtectedString name="Source"><![CDATA[--!strict
1760
- -- ============================================================================
1761
- -- Skill Module: preview_asset
1762
- -- Deskripsi: Pratinjau & Pemindaian Keamanan Aset Creator Store Secara Terisolasi
1763
- -- ============================================================================
2051
+ -- ══════════════════════════════════════════════════════════════════════
2052
+ -- NVStudio Subsystem: nv_auto_rigger
2053
+ -- Deskripsi: Mesin Auto-Rigging Deterministik & Heuristik Geometris
2054
+ -- Membangun struktur rig lengkap untuk Avatar R6, R15, Senjata/Prop,
2055
+ -- dan Assembly Model secara instan dengan zero-dislocation math.
2056
+ -- ══════════════════════════════════════════════════════════════════════
1764
2057
 
1765
- local HttpService = game:GetService("HttpService")
1766
- local InsertService = game:GetService("InsertService")
2058
+ local jointMathMod = script.Parent:WaitForChild("nv_joint_math")
2059
+ local JointMath = require(jointMathMod)
2060
+
2061
+ local AutoRigger = {}
2062
+
2063
+ -- ── Helper: Dapatkan atau buat Motor6D ─────────────────────────────────────────
2064
+ local function createOrUpdateMotor(name: string, part0: BasePart, part1: BasePart, parent: Instance?): Motor6D
2065
+ part1.Anchored = false
2066
+ local existing = part0:FindFirstChild(name) or part1:FindFirstChild(name)
2067
+ local motor: Motor6D
2068
+ if existing and existing:IsA("Motor6D") then
2069
+ motor = existing
2070
+ else
2071
+ motor = Instance.new("Motor6D")
2072
+ motor.Name = name
2073
+ end
2074
+
2075
+ motor.Part0 = part0
2076
+ motor.Part1 = part1
2077
+ local c0, c1 = JointMath.computeInitialOffsets(part0, part1)
2078
+ motor.C0 = c0
2079
+ motor.C1 = c1
2080
+ motor.Parent = parent or part0
2081
+ return motor
2082
+ end
2083
+
2084
+ -- ── 1. Auto-Rig R6 Avatar ─────────────────────────────────────────────────────
2085
+ function AutoRigger.rigR6(model: Model): { [string]: any }
2086
+ assert(model and model:IsA("Model"), "Target harus berupa Model")
2087
+
2088
+ local parts: { [string]: BasePart } = {}
2089
+ for _, desc in ipairs(model:GetDescendants()) do
2090
+ if desc:IsA("BasePart") then
2091
+ parts[desc.Name] = desc
2092
+ end
2093
+ end
2094
+
2095
+ local torso = parts["Torso"] or parts["UpperTorso"]
2096
+ if not torso then
2097
+ return { status = "error", error = "Model tidak memiliki part 'Torso' untuk R6 rig." }
2098
+ end
2099
+
2100
+ local rootPart = parts["HumanoidRootPart"]
2101
+ if not rootPart then
2102
+ -- Buat HumanoidRootPart transparan jika belum ada
2103
+ rootPart = Instance.new("Part")
2104
+ rootPart.Name = "HumanoidRootPart"
2105
+ rootPart.Size = Vector3.new(2, 2, 1)
2106
+ rootPart.Transparency = 1
2107
+ rootPart.CanCollide = false
2108
+ rootPart.CFrame = torso.CFrame
2109
+ rootPart.Parent = model
2110
+ parts["HumanoidRootPart"] = rootPart
2111
+ model.PrimaryPart = rootPart
2112
+ end
2113
+
2114
+ local created = {}
2115
+
2116
+ -- 1. RootJoint: HumanoidRootPart -> Torso
2117
+ local rootJoint = createOrUpdateMotor("RootJoint", rootPart, torso, rootPart)
2118
+ table.insert(created, "RootJoint")
2119
+
2120
+ -- 2. Neck: Torso -> Head
2121
+ if parts["Head"] then
2122
+ createOrUpdateMotor("Neck", torso, parts["Head"], torso)
2123
+ table.insert(created, "Neck")
2124
+ end
2125
+
2126
+ -- 3. Shoulders: Torso -> Arms
2127
+ local lArm = parts["Left Arm"] or parts["LeftArm"]
2128
+ if lArm then
2129
+ createOrUpdateMotor("Left Shoulder", torso, lArm, torso)
2130
+ table.insert(created, "Left Shoulder")
2131
+ end
2132
+
2133
+ local rArm = parts["Right Arm"] or parts["RightArm"]
2134
+ if rArm then
2135
+ createOrUpdateMotor("Right Shoulder", torso, rArm, torso)
2136
+ table.insert(created, "Right Shoulder")
2137
+ end
2138
+
2139
+ -- 4. Hips: Torso -> Legs
2140
+ local lLeg = parts["Left Leg"] or parts["LeftLeg"]
2141
+ if lLeg then
2142
+ createOrUpdateMotor("Left Hip", torso, lLeg, torso)
2143
+ table.insert(created, "Left Hip")
2144
+ end
2145
+
2146
+ local rLeg = parts["Right Leg"] or parts["RightLeg"]
2147
+ if rLeg then
2148
+ createOrUpdateMotor("Right Hip", torso, rLeg, torso)
2149
+ table.insert(created, "Right Hip")
2150
+ end
2151
+
2152
+ -- Pastikan Humanoid bertipe R6
2153
+ local humanoid = model:FindFirstChildOfClass("Humanoid")
2154
+ if humanoid then
2155
+ humanoid.RigType = Enum.HumanoidRigType.R6
2156
+ end
2157
+
2158
+ return {
2159
+ status = "success",
2160
+ rigType = "R6",
2161
+ jointsCreated = #created,
2162
+ jointNames = created,
2163
+ rootPart = rootPart.Name
2164
+ }
2165
+ end
2166
+
2167
+ -- ── 2. Auto-Rig R15 Avatar ────────────────────────────────────────────────────
2168
+ function AutoRigger.rigR15(model: Model): { [string]: any }
2169
+ assert(model and model:IsA("Model"), "Target harus berupa Model")
2170
+
2171
+ local parts: { [string]: BasePart } = {}
2172
+ for _, desc in ipairs(model:GetDescendants()) do
2173
+ if desc:IsA("BasePart") then
2174
+ parts[desc.Name] = desc
2175
+ end
2176
+ end
2177
+
2178
+ local lowerTorso = parts["LowerTorso"]
2179
+ local upperTorso = parts["UpperTorso"]
2180
+ if not lowerTorso or not upperTorso then
2181
+ return { status = "error", error = "Model tidak memiliki 'LowerTorso' dan 'UpperTorso' untuk R15 rig." }
2182
+ end
2183
+
2184
+ local rootPart = parts["HumanoidRootPart"]
2185
+ if not rootPart then
2186
+ rootPart = Instance.new("Part")
2187
+ rootPart.Name = "HumanoidRootPart"
2188
+ rootPart.Size = Vector3.new(2, 2, 1)
2189
+ rootPart.Transparency = 1
2190
+ rootPart.CanCollide = false
2191
+ rootPart.CFrame = lowerTorso.CFrame
2192
+ rootPart.Parent = model
2193
+ parts["HumanoidRootPart"] = rootPart
2194
+ model.PrimaryPart = rootPart
2195
+ end
2196
+
2197
+ local r15Hierarchy = {
2198
+ { name = "Root", p0 = "HumanoidRootPart", p1 = "LowerTorso" },
2199
+ { name = "Waist", p0 = "LowerTorso", p1 = "UpperTorso" },
2200
+ { name = "Neck", p0 = "UpperTorso", p1 = "Head" },
2201
+
2202
+ -- Left Arm Chain
2203
+ { name = "LeftShoulder", p0 = "UpperTorso", p1 = "LeftUpperArm" },
2204
+ { name = "LeftElbow", p0 = "LeftUpperArm", p1 = "LeftLowerArm" },
2205
+ { name = "LeftWrist", p0 = "LeftLowerArm", p1 = "LeftHand" },
2206
+
2207
+ -- Right Arm Chain
2208
+ { name = "RightShoulder", p0 = "UpperTorso", p1 = "RightUpperArm" },
2209
+ { name = "RightElbow", p0 = "RightUpperArm", p1 = "RightLowerArm" },
2210
+ { name = "RightWrist", p0 = "RightLowerArm", p1 = "RightHand" },
2211
+
2212
+ -- Left Leg Chain
2213
+ { name = "LeftHip", p0 = "LowerTorso", p1 = "LeftUpperLeg" },
2214
+ { name = "LeftKnee", p0 = "LeftUpperLeg", p1 = "LeftLowerLeg" },
2215
+ { name = "LeftAnkle", p0 = "LeftLowerLeg", p1 = "LeftFoot" },
2216
+
2217
+ -- Right Leg Chain
2218
+ { name = "RightHip", p0 = "LowerTorso", p1 = "RightUpperLeg" },
2219
+ { name = "RightKnee", p0 = "RightUpperLeg", p1 = "RightLowerLeg" },
2220
+ { name = "RightAnkle", p0 = "RightLowerLeg", p1 = "RightFoot" }
2221
+ }
2222
+
2223
+ local created = {}
2224
+ for _, item in ipairs(r15Hierarchy) do
2225
+ local p0 = parts[item.p0]
2226
+ local p1 = parts[item.p1]
2227
+ if p0 and p1 then
2228
+ createOrUpdateMotor(item.name, p0, p1, p0)
2229
+ table.insert(created, item.name)
2230
+ end
2231
+ end
2232
+
2233
+ local humanoid = model:FindFirstChildOfClass("Humanoid")
2234
+ if humanoid then
2235
+ humanoid.RigType = Enum.HumanoidRigType.R15
2236
+ end
2237
+
2238
+ return {
2239
+ status = "success",
2240
+ rigType = "R15",
2241
+ jointsCreated = #created,
2242
+ jointNames = created,
2243
+ rootPart = rootPart.Name
2244
+ }
2245
+ end
2246
+
2247
+ -- ── 3. Rig Weapon / Tool ke Karakter ──────────────────────────────────────────
2248
+ function AutoRigger.rigWeapon(characterModel: Model, weaponInstance: Instance, targetLimbName: string?): { [string]: any }
2249
+ assert(characterModel and characterModel:IsA("Model"), "Character target harus berupa Model")
2250
+ assert(weaponInstance and weaponInstance:IsA("Instance"), "Weapon target tidak valid")
2251
+
2252
+ local parts: { [string]: BasePart } = {}
2253
+ for _, desc in ipairs(characterModel:GetDescendants()) do
2254
+ if desc:IsA("BasePart") then
2255
+ parts[desc.Name] = desc
2256
+ end
2257
+ end
2258
+
2259
+ local limb = targetLimbName and parts[targetLimbName]
2260
+ if not limb then
2261
+ limb = parts["RightHand"] or parts["Right Arm"] or parts["RightArm"] or parts["HumanoidRootPart"]
2262
+ end
2263
+ if not limb then
2264
+ return { status = "error", error = "Tidak dapat menemukan anggota tubuh target (RightHand / Right Arm) pada karakter." }
2265
+ end
2266
+
2267
+ -- Cari handle pada senjata
2268
+ local weaponHandle: BasePart? = nil
2269
+ if weaponInstance:IsA("BasePart") then
2270
+ weaponHandle = weaponInstance
2271
+ elseif weaponInstance:IsA("Model") or weaponInstance:IsA("Tool") then
2272
+ weaponHandle = (weaponInstance :: any).PrimaryPart
2273
+ or weaponInstance:FindFirstChild("Handle") :: BasePart?
2274
+ or weaponInstance:FindFirstChildWhichIsA("BasePart")
2275
+ end
2276
+
2277
+ if not weaponHandle then
2278
+ return { status = "error", error = "Senjata tidak memiliki BasePart atau Handle." }
2279
+ end
2280
+
2281
+ -- 1. Buat Motor6D penaut karakter ke Handle
2282
+ local weaponMotor = createOrUpdateMotor("WeaponGrip", limb, weaponHandle, limb)
2283
+
2284
+ -- 2. Las seluruh bagian senjata lain ke Handle
2285
+ local weldedCount = 0
2286
+ if weaponInstance ~= weaponHandle then
2287
+ for _, desc in ipairs(weaponInstance:GetDescendants()) do
2288
+ if desc:IsA("BasePart") and desc ~= weaponHandle then
2289
+ desc.Anchored = false
2290
+ local weld = desc:FindFirstChild("WeldToHandle") or Instance.new("Weld")
2291
+ weld.Name = "WeldToHandle"
2292
+ weld.Part0 = weaponHandle
2293
+ weld.Part1 = desc
2294
+ weld.C0 = weaponHandle.CFrame:ToObjectSpace(desc.CFrame)
2295
+ weld.C1 = CFrame.identity
2296
+ weld.Parent = weaponHandle
2297
+ weldedCount += 1
2298
+ end
2299
+ end
2300
+ end
2301
+
2302
+ return {
2303
+ status = "success",
2304
+ attachedTo = limb.Name,
2305
+ handleName = weaponHandle.Name,
2306
+ weldedComponents = weldedCount,
2307
+ message = string.format("Senjata '%s' berhasil dirig ke '%s' (%d part dilas)", weaponInstance.Name, limb.Name, weldedCount)
2308
+ }
2309
+ end
2310
+
2311
+ -- ── 4. Weld Assembly (Props, Armor, Kendaraan) ────────────────────────────────
2312
+ function AutoRigger.weldAssembly(container: Instance, primaryPart: BasePart?): { [string]: any }
2313
+ assert(container and container:IsA("Instance"), "Container tidak valid")
2314
+
2315
+ local rootPart: BasePart? = primaryPart
2316
+ if not rootPart and container:IsA("Model") then
2317
+ rootPart = container.PrimaryPart
2318
+ end
2319
+
2320
+ local allParts: { BasePart } = {}
2321
+ for _, desc in ipairs(container:GetDescendants()) do
2322
+ if desc:IsA("BasePart") then
2323
+ table.insert(allParts, desc)
2324
+ end
2325
+ end
2326
+ if container:IsA("BasePart") then
2327
+ table.insert(allParts, container)
2328
+ end
2329
+
2330
+ if #allParts == 0 then
2331
+ return { status = "error", error = "Tidak ada BasePart di dalam kontainer untuk dilas." }
2332
+ end
2333
+
2334
+ -- Jika belum ada rootPart, tentukan berdasarkan volume terbesar
2335
+ if not rootPart then
2336
+ local maxVol = -1
2337
+ for _, p in ipairs(allParts) do
2338
+ local vol = p.Size.X * p.Size.Y * p.Size.Z
2339
+ if vol > maxVol then
2340
+ maxVol = vol
2341
+ rootPart = p
2342
+ end
2343
+ end
2344
+ end
2345
+
2346
+ if not rootPart then
2347
+ return { status = "error", error = "Gagal menentukan Root Part untuk pengelasan." }
2348
+ end
2349
+
2350
+ local weldsCreated = 0
2351
+ for _, p in ipairs(allParts) do
2352
+ if p ~= rootPart then
2353
+ p.Anchored = false
2354
+ local w = p:FindFirstChild("AssemblyWeld") or Instance.new("Weld")
2355
+ w.Name = "AssemblyWeld"
2356
+ w.Part0 = rootPart
2357
+ w.Part1 = p
2358
+ w.C0 = rootPart.CFrame:ToObjectSpace(p.CFrame)
2359
+ w.C1 = CFrame.identity
2360
+ w.Parent = rootPart
2361
+ weldsCreated += 1
2362
+ end
2363
+ end
2364
+
2365
+ return {
2366
+ status = "success",
2367
+ rootPart = rootPart.Name,
2368
+ weldsCreated = weldsCreated,
2369
+ message = string.format("Berhasil mengelas %d part ke Root '%s'", weldsCreated, rootPart.Name)
2370
+ }
2371
+ end
2372
+
2373
+ -- ── 5. Spatial Geometric Limb Analyzer (Heuristik Bounding Box) ───────────────
2374
+ -- Menganalisis model dengan nama part acak/impor dan mengklasifikasikan anggota tubuh
2375
+ function AutoRigger.discoverLimbsBySpatialGeometry(model: Model): { [string]: BasePart }
2376
+ local parts: { BasePart } = {}
2377
+ for _, desc in ipairs(model:GetDescendants()) do
2378
+ if desc:IsA("BasePart") then
2379
+ table.insert(parts, desc)
2380
+ end
2381
+ end
2382
+
2383
+ if #parts == 0 then return {} end
2384
+
2385
+ -- 1. Hitung bounding box pusat model
2386
+ local cframe, size = model:GetBoundingBox()
2387
+ local center = cframe.Position
2388
+
2389
+ -- 2. Temukan torso (part dengan volume besar di sekitar pusat vertikal)
2390
+ local bestTorso: BasePart = parts[1]
2391
+ local bestScore = -1e9
2392
+
2393
+ for _, p in ipairs(parts) do
2394
+ local vol = p.Size.X * p.Size.Y * p.Size.Z
2395
+ local distToCenter = (p.Position - center).Magnitude
2396
+ local score = vol / (distToCenter + 1)
2397
+ if score > bestScore then
2398
+ bestScore = score
2399
+ bestTorso = p
2400
+ end
2401
+ end
2402
+
2403
+ local identified: { [string]: BasePart } = {
2404
+ Torso = bestTorso
2405
+ }
2406
+
2407
+ local torsoPos = bestTorso.Position
2408
+
2409
+ -- 3. Klasifikasi part lainnya relatif terhadap Torso
2410
+ for _, p in ipairs(parts) do
2411
+ if p ~= bestTorso then
2412
+ local relPos = bestTorso.CFrame:PointToObjectSpace(p.Position)
2413
+
2414
+ if relPos.Y > bestTorso.Size.Y * 0.5 then
2415
+ -- Bagian atas -> Head
2416
+ if not identified["Head"] or p.Position.Y > identified["Head"].Position.Y then
2417
+ identified["Head"] = p
2418
+ end
2419
+ elseif relPos.Y < -bestTorso.Size.Y * 0.3 then
2420
+ -- Bagian bawah -> Legs
2421
+ if relPos.X < 0 then
2422
+ identified["Left Leg"] = p
2423
+ else
2424
+ identified["Right Leg"] = p
2425
+ end
2426
+ else
2427
+ -- Bagian samping -> Arms
2428
+ if relPos.X < 0 then
2429
+ identified["Left Arm"] = p
2430
+ else
2431
+ identified["Right Arm"] = p
2432
+ end
2433
+ end
2434
+ end
2435
+ end
2436
+
2437
+ return identified
2438
+ end
2439
+
2440
+ return AutoRigger
2441
+
2442
+ ]]></ProtectedString>
2443
+ </Properties>
2444
+ </Item>
2445
+ <Item class="ModuleScript" referent="RBX_nv_curves">
2446
+ <Properties>
2447
+ <BinaryString name="AttributesSerialize"></BinaryString>
2448
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
2449
+ <bool name="DefinesCapabilities">false</bool>
2450
+ <bool name="Disabled">false</bool>
2451
+ <Content name="LinkedSource"><null></null></Content>
2452
+ <string name="Name">nv_curves</string>
2453
+ <int64 name="SourceAssetId">-1</int64>
2454
+ <BinaryString name="Tags"></BinaryString>
2455
+ <ProtectedString name="Source"><![CDATA[--!strict
2456
+ --!native
2457
+ -- ══════════════════════════════════════════════════════════════════════
2458
+ -- NVStudio Subsystem: nv_curves
2459
+ -- Deskripsi: Mesin Kurva Cubic Bezier, Easing Functions & Quaternion SLERP
2460
+ -- Menghitung interpolasi waktu dan rotasi sendi secara ultra-halus
2461
+ -- layaknya kurva Graph Editor animator profesional.
2462
+ -- ══════════════════════════════════════════════════════════════════════
2463
+
2464
+ local Curves = {}
2465
+ local PI = math.pi
2466
+ local HALF_PI = math.pi * 0.5
2467
+ local TAU = math.pi * 2
2468
+ local DEFAULT_OVERSHOOT = 1.70158
2469
+
2470
+ -- ── 1. Cubic Bezier Solver ───────────────────────────────────────────────────
2471
+ function Curves.bezierParameter(alpha: number, x1: number, x2: number): number
2472
+ if alpha <= 0 then return 0 end
2473
+ if alpha >= 1 then return 1 end
2474
+
2475
+ x1 = math.clamp(x1, 0, 1)
2476
+ x2 = math.clamp(x2, 0, 1)
2477
+
2478
+ local lo, hi, u = 0, 1, alpha
2479
+ for _ = 1, 32 do
2480
+ local v = 1 - u
2481
+ local x = 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u
2482
+ local delta = x - alpha
2483
+ if math.abs(delta) <= 1e-7 then
2484
+ break
2485
+ end
2486
+
2487
+ if delta < 0 then
2488
+ lo = u
2489
+ else
2490
+ hi = u
2491
+ end
2492
+
2493
+ local deriv = 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2)
2494
+ local cand = deriv > 1e-7 and (u - delta / deriv) or -1
2495
+ u = (cand > lo and cand < hi) and cand or (lo + hi) * 0.5
2496
+ end
2497
+
2498
+ return u
2499
+ end
2500
+
2501
+ function Curves.cubic(a: number, b: number, c: number, d: number, u: number): number
2502
+ local v = 1 - u
2503
+ return a * (v * v * v) + b * (3 * v * v * u) + c * (3 * v * u * u) + d * (u * u * u)
2504
+ end
2505
+
2506
+ function Curves.bezierAlpha(t: number, x1: number?, y1: number?, x2: number?, y2: number?): number
2507
+ if t <= 0 then return 0 end
2508
+ if t >= 1 then return 1 end
2509
+
2510
+ local px1 = x1 or 0.42
2511
+ local py1 = y1 or 0.0
2512
+ local px2 = x2 or 0.58
2513
+ local py2 = y2 or 1.0
2514
+
2515
+ local u = Curves.bezierParameter(t, px1, px2)
2516
+ return Curves.cubic(0, py1, py2, 1, u)
2517
+ end
2518
+
2519
+ -- ── 2. Standard Easing Functions ─────────────────────────────────────────────
2520
+ local EasingTable: { [string]: (t: number) -> number } = {
2521
+ Linear = function(t) return t end,
2522
+
2523
+ SineIn = function(t) return 1 - math.cos(t * HALF_PI) end,
2524
+ SineOut = function(t) return math.sin(t * HALF_PI) end,
2525
+ SineInOut = function(t) return -(math.cos(PI * t) - 1) * 0.5 end,
2526
+
2527
+ QuadIn = function(t) return t * t end,
2528
+ QuadOut = function(t) return t * (2 - t) end,
2529
+ QuadInOut = function(t) return t < 0.5 and (2 * t * t) or (-1 + (4 - 2 * t) * t) end,
2530
+
2531
+ CubicIn = function(t) return t * t * t end,
2532
+ CubicOut = function(t) local f = t - 1 return f * f * f + 1 end,
2533
+ CubicInOut = function(t) return t < 0.5 and (4 * t * t * t) or ((t - 1) * (2 * t - 2) * (2 * t - 2) + 1) end,
2534
+
2535
+ QuartIn = function(t) return t * t * t * t end,
2536
+ QuartOut = function(t) local f = t - 1 return 1 - f * f * f * f end,
2537
+ QuartInOut = function(t) local f = t - 1 return t < 0.5 and (8 * t * t * t * t) or (1 - 8 * f * f * f * f) end,
2538
+
2539
+ ExpoIn = function(t) return t == 0 and 0 or math.pow(2, 10 * (t - 1)) end,
2540
+ ExpoOut = function(t) return t == 1 and 1 or (1 - math.pow(2, -10 * t)) end,
2541
+ ExpoInOut = function(t)
2542
+ if t == 0 then return 0 end
2543
+ if t == 1 then return 1 end
2544
+ if t < 0.5 then return math.pow(2, 20 * t - 10) * 0.5 end
2545
+ return (2 - math.pow(2, -20 * t + 10)) * 0.5
2546
+ end,
2547
+
2548
+ BackIn = function(t) local s = DEFAULT_OVERSHOOT return t * t * ((s + 1) * t - s) end,
2549
+ BackOut = function(t) local s = DEFAULT_OVERSHOOT local f = t - 1 return f * f * ((s + 1) * f + s) + 1 end,
2550
+ BackInOut = function(t)
2551
+ local s = DEFAULT_OVERSHOOT * 1.525
2552
+ local f = t * 2
2553
+ if f < 1 then return 0.5 * (f * f * ((s + 1) * f - s)) end
2554
+ f = f - 2
2555
+ return 0.5 * (f * f * ((s + 1) * f + s) + 2)
2556
+ end,
2557
+
2558
+ BounceOut = function(t)
2559
+ if t < (1 / 2.75) then
2560
+ return 7.5625 * t * t
2561
+ elseif t < (2 / 2.75) then
2562
+ local f = t - (1.5 / 2.75)
2563
+ return 7.5625 * f * f + 0.75
2564
+ elseif t < (2.5 / 2.75) then
2565
+ local f = t - (2.25 / 2.75)
2566
+ return 7.5625 * f * f + 0.9375
2567
+ else
2568
+ local f = t - (2.625 / 2.75)
2569
+ return 7.5625 * f * f + 0.984375
2570
+ end
2571
+ end,
2572
+ BounceIn = function(t)
2573
+ local fn = nil
2574
+ -- forward declaration
2575
+ return 1 - (function(x)
2576
+ if x < (1 / 2.75) then return 7.5625 * x * x
2577
+ elseif x < (2 / 2.75) then local f = x - (1.5 / 2.75) return 7.5625 * f * f + 0.75
2578
+ elseif x < (2.5 / 2.75) then local f = x - (2.25 / 2.75) return 7.5625 * f * f + 0.9375
2579
+ else local f = x - (2.625 / 2.75) return 7.5625 * f * f + 0.984375 end
2580
+ end)(1 - t)
2581
+ end,
2582
+ BounceInOut = function(t)
2583
+ local bounceOut = function(x: number): number
2584
+ if x < (1 / 2.75) then return 7.5625 * x * x
2585
+ elseif x < (2 / 2.75) then local f = x - (1.5 / 2.75) return 7.5625 * f * f + 0.75
2586
+ elseif x < (2.5 / 2.75) then local f = x - (2.25 / 2.75) return 7.5625 * f * f + 0.9375
2587
+ else local f = x - (2.625 / 2.75) return 7.5625 * f * f + 0.984375 end
2588
+ end
2589
+ if t < 0.5 then
2590
+ return (1 - bounceOut(1 - t * 2)) * 0.5
2591
+ end
2592
+ return bounceOut(t * 2 - 1) * 0.5 + 0.5
2593
+ end,
2594
+
2595
+ ElasticOut = function(t)
2596
+ if t == 0 then return 0 end
2597
+ if t == 1 then return 1 end
2598
+ local p = 0.3
2599
+ local s = p / 4
2600
+ return math.pow(2, -10 * t) * math.sin((t - s) * TAU / p) + 1
2601
+ end,
2602
+ ElasticIn = function(t)
2603
+ if t == 0 then return 0 end
2604
+ if t == 1 then return 1 end
2605
+ local p = 0.3
2606
+ local s = p / 4
2607
+ local f = t - 1
2608
+ return -(math.pow(2, 10 * f) * math.sin((f - s) * TAU / p))
2609
+ end,
2610
+ ElasticInOut = function(t)
2611
+ if t == 0 then return 0 end
2612
+ if t == 1 then return 1 end
2613
+ local p = 0.3 * 1.5
2614
+ local s = p / 4
2615
+ local f = t * 2 - 1
2616
+ if f < 0 then
2617
+ return -0.5 * (math.pow(2, 10 * f) * math.sin((f - s) * TAU / p))
2618
+ end
2619
+ return math.pow(2, -10 * f) * math.sin((f - s) * TAU / p) * 0.5 + 1
2620
+ end
2621
+ }
2622
+
2623
+ function Curves.evaluate(easingName: string?, t: number): number
2624
+ t = math.clamp(t, 0, 1)
2625
+ local name = easingName or "QuadInOut"
2626
+ local fn = EasingTable[name]
2627
+ if fn then
2628
+ return fn(t)
2629
+ end
2630
+ return t
2631
+ end
2632
+
2633
+ -- ── 3. Quaternion SLERP (Spherical Linear Interpolation) ──────────────────────
2634
+ -- Representasi Quaternion: { x, y, z, w }
2635
+ type Quat = { x: number, y: number, z: number, w: number }
2636
+
2637
+ local function cframeToQuat(cf: CFrame): (Vector3, Quat)
2638
+ local _, _, _, m00, m01, m02, m10, m11, m12, m20, m21, m22 = cf:GetComponents()
2639
+ local trace = m00 + m11 + m22
2640
+ local q: Quat
2641
+ if trace > 0 then
2642
+ local s = math.sqrt(trace + 1) * 2
2643
+ q = { x = (m21 - m12) / s, y = (m02 - m20) / s, z = (m10 - m01) / s, w = 0.25 * s }
2644
+ elseif (m00 > m11) and (m00 > m22) then
2645
+ local s = math.sqrt(1 + m00 - m11 - m22) * 2
2646
+ q = { x = 0.25 * s, y = (m01 + m10) / s, z = (m02 + m20) / s, w = (m21 - m12) / s }
2647
+ elseif m11 > m22 then
2648
+ local s = math.sqrt(1 + m11 - m00 - m22) * 2
2649
+ q = { x = (m01 + m10) / s, y = 0.25 * s, z = (m12 + m21) / s, w = (m02 - m20) / s }
2650
+ else
2651
+ local s = math.sqrt(1 + m22 - m00 - m11) * 2
2652
+ q = { x = (m02 + m20) / s, y = (m12 + m21) / s, z = 0.25 * s, w = (m10 - m01) / s }
2653
+ end
2654
+ return cf.Position, q
2655
+ end
2656
+
2657
+ local function quatToCFrame(pos: Vector3, q: Quat): CFrame
2658
+ local x, y, z, w = q.x, q.y, q.z, q.w
2659
+ local xx, yy, zz = x * x, y * y, z * z
2660
+ local xy, xz, yz = x * y, x * z, y * z
2661
+ local wx, wy, wz = w * x, w * y, w * z
2662
+
2663
+ return CFrame.new(
2664
+ pos.X, pos.Y, pos.Z,
2665
+ 1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy),
2666
+ 2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx),
2667
+ 2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy)
2668
+ )
2669
+ end
2670
+
2671
+ function Curves.slerp(cfA: CFrame, cfB: CFrame, alpha: number): CFrame
2672
+ if alpha <= 0 then return cfA end
2673
+ if alpha >= 1 then return cfB end
2674
+
2675
+ local posA, qA = cframeToQuat(cfA)
2676
+ local posB, qB = cframeToQuat(cfB)
2677
+
2678
+ local cosHalfTheta = qA.x * qB.x + qA.y * qB.y + qA.z * qB.z + qA.w * qB.w
2679
+ if cosHalfTheta < 0 then
2680
+ qB = { x = -qB.x, y = -qB.y, z = -qB.z, w = -qB.w }
2681
+ cosHalfTheta = -cosHalfTheta
2682
+ end
2683
+
2684
+ local qResult: Quat
2685
+ if math.abs(cosHalfTheta) >= 1 then
2686
+ qResult = qA
2687
+ else
2688
+ local halfTheta = math.acos(math.clamp(cosHalfTheta, -1, 1))
2689
+ local sinHalfTheta = math.sqrt(1 - cosHalfTheta * cosHalfTheta)
2690
+ if math.abs(sinHalfTheta) < 1e-4 then
2691
+ qResult = {
2692
+ x = qA.x * (1 - alpha) + qB.x * alpha,
2693
+ y = qA.y * (1 - alpha) + qB.y * alpha,
2694
+ z = qA.z * (1 - alpha) + qB.z * alpha,
2695
+ w = qA.w * (1 - alpha) + qB.w * alpha,
2696
+ }
2697
+ else
2698
+ local ratioA = math.sin((1 - alpha) * halfTheta) / sinHalfTheta
2699
+ local ratioB = math.sin(alpha * halfTheta) / sinHalfTheta
2700
+ qResult = {
2701
+ x = qA.x * ratioA + qB.x * ratioB,
2702
+ y = qA.y * ratioA + qB.y * ratioB,
2703
+ z = qA.z * ratioA + qB.z * ratioB,
2704
+ w = qA.w * ratioA + qB.w * ratioB,
2705
+ }
2706
+ end
2707
+ end
2708
+
2709
+ local interpPos = posA:Lerp(posB, alpha)
2710
+ return quatToCFrame(interpPos, qResult)
2711
+ end
2712
+
2713
+ return Curves
2714
+
2715
+ ]]></ProtectedString>
2716
+ </Properties>
2717
+ </Item>
2718
+ <Item class="ModuleScript" referent="RBX_nv_joint_math">
2719
+ <Properties>
2720
+ <BinaryString name="AttributesSerialize"></BinaryString>
2721
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
2722
+ <bool name="DefinesCapabilities">false</bool>
2723
+ <bool name="Disabled">false</bool>
2724
+ <Content name="LinkedSource"><null></null></Content>
2725
+ <string name="Name">nv_joint_math</string>
2726
+ <int64 name="SourceAssetId">-1</int64>
2727
+ <BinaryString name="Tags"></BinaryString>
2728
+ <ProtectedString name="Source"><![CDATA[--!strict
2729
+ -- ══════════════════════════════════════════════════════════════════════
2730
+ -- NVStudio Subsystem: nv_joint_math
2731
+ -- Deskripsi: Modul Matematika Kinematika Sambungan (Joints) & Transformasi
2732
+ -- Menyediakan kalkulasi C0/C1 presisi, pemindahan poros tanpa dislokasi,
2733
+ -- rotasi poros, penyelarasan koordinat, dan pencerminan simetri bilateral.
2734
+ -- ══════════════════════════════════════════════════════════════════════
2735
+
2736
+ local JointMath = {}
2737
+
2738
+ local EPSILON = 1e-6
2739
+
2740
+ -- ── 1. Hitung Offset Awal C0 & C1 ─────────────────────────────────────────────
2741
+ -- Menghubungkan Part0 dan Part1 tanpa mengubah posisi relatif fisik saat ini
2742
+ function JointMath.computeInitialOffsets(part0: BasePart, part1: BasePart): (CFrame, CFrame)
2743
+ local c0 = part0.CFrame:ToObjectSpace(part1.CFrame)
2744
+ local c1 = CFrame.identity
2745
+ return c0, c1
2746
+ end
2747
+
2748
+ -- ── 2. Geser Poros Sendi di Koordinat Dunia (World Delta) ─────────────────────
2749
+ -- Menggeser titik pivot tanpa mendislokasi posisi part anak di dunia 3D
2750
+ function JointMath.offsetPivot(joint: JointInstance, deltaWorld: Vector3): boolean
2751
+ local part0 = joint.Part0
2752
+ local part1 = joint.Part1
2753
+ if not part0 or not part1 then return false end
2754
+
2755
+ local origJointWorld = part0.CFrame * joint.C0
2756
+ local desiredWorld = origJointWorld + deltaWorld
2757
+
2758
+ local newC0 = part0.CFrame:ToObjectSpace(desiredWorld)
2759
+ local diff = joint.C0:ToObjectSpace(newC0)
2760
+ local newC1 = joint.C1 * diff
2761
+
2762
+ joint.C0 = newC0
2763
+ joint.C1 = newC1
2764
+ return true
2765
+ end
2766
+
2767
+ -- ── 3. Tetapkan Posisi Poros ke Titik Dunia Absolut (Set World Position) ──────
2768
+ -- Memindahkan titik tumpu sendi tepat ke koordinat 3D yang diinginkan
2769
+ function JointMath.setPivotWorld(joint: JointInstance, targetWorldPos: Vector3): boolean
2770
+ local part0 = joint.Part0
2771
+ local part1 = joint.Part1
2772
+ if not part0 or not part1 then return false end
2773
+
2774
+ local origJointWorld = part0.CFrame * joint.C0
2775
+ local targetWorldCF = CFrame.new(targetWorldPos) * origJointWorld.Rotation
2776
+
2777
+ local newC0 = part0.CFrame:ToObjectSpace(targetWorldCF)
2778
+ local diff = joint.C0:ToObjectSpace(newC0)
2779
+ local newC1 = joint.C1 * diff
2780
+
2781
+ joint.C0 = newC0
2782
+ joint.C1 = newC1
2783
+ return true
2784
+ end
2785
+
2786
+ -- ── 4. Rotasi Poros Sendi Tanpa Merusak Rig ───────────────────────────────────
2787
+ -- Memutar orientasi poros sendi sebesar sudut Euler radian
2788
+ function JointMath.rotatePivot(joint: JointInstance, rotEulerRad: Vector3): boolean
2789
+ local part0 = joint.Part0
2790
+ local part1 = joint.Part1
2791
+ if not part0 or not part1 then return false end
2792
+
2793
+ local rotCF = CFrame.Angles(rotEulerRad.X, rotEulerRad.Y, rotEulerRad.Z)
2794
+ joint.C0 = joint.C0 * rotCF
2795
+ joint.C1 = joint.C1 * rotCF
2796
+ return true
2797
+ end
2798
+
2799
+ -- ── 5. Reset Poros Sendi ke Pusat Part Anak ───────────────────────────────────
2800
+ -- Mengembalikan poros sendi ke origin geometris Part1
2801
+ function JointMath.resetPivot(joint: JointInstance): boolean
2802
+ local part0 = joint.Part0
2803
+ local part1 = joint.Part1
2804
+ if not part0 or not part1 then return false end
2805
+
2806
+ joint.C0 = part0.CFrame:ToObjectSpace(part1.CFrame)
2807
+ joint.C1 = CFrame.identity
2808
+ return true
2809
+ end
2810
+
2811
+ -- ── 6. Selaraskan Orientasi Poros ke Part0 atau Part1 ─────────────────────────
2812
+ -- Menjaga posisi poros di dunia tetap sama, tetapi rotasinya mengikuti part referensi
2813
+ function JointMath.alignJoint(joint: JointInstance, alignTo: "Part0" | "Part1"): boolean
2814
+ local part0 = joint.Part0
2815
+ local part1 = joint.Part1
2816
+ if not part0 or not part1 then return false end
2817
+
2818
+ local pivotWorldPos = (part0.CFrame * joint.C0).Position
2819
+ local refCF = alignTo == "Part0" and part0.CFrame or part1.CFrame
2820
+ local targetWorldCF = CFrame.new(pivotWorldPos) * refCF.Rotation
2821
+
2822
+ local newC0 = part0.CFrame:ToObjectSpace(targetWorldCF)
2823
+ local diff = joint.C0:ToObjectSpace(newC0)
2824
+ local newC1 = joint.C1 * diff
2825
+
2826
+ joint.C0 = newC0
2827
+ joint.C1 = newC1
2828
+ return true
2829
+ end
2830
+
2831
+ -- ── 7. Refleksi CFrame Sumbu X Simetris (Bilateral Mirroring) ──────────────────
2832
+ -- Mencerminkan CFrame melewati bidang sagittal (sumbu X)
2833
+ function JointMath.mirrorCFrame(cf: CFrame): CFrame
2834
+ local x, y, z,
2835
+ r00, r01, r02,
2836
+ r10, r11, r12,
2837
+ r20, r21, r22 = cf:GetComponents()
2838
+
2839
+ -- Refleksi koordinat posisi dan rotasi M_x * R * M_x
2840
+ return CFrame.new(
2841
+ -x, y, z,
2842
+ r00, -r01, -r02,
2843
+ -r10, r11, r12,
2844
+ -r20, r21, r22
2845
+ )
2846
+ end
2847
+
2848
+ -- ── 8. Cerminkan Sendi Bilateral Kiri <-> Kanan ────────────────────────────────
2849
+ -- Menerapkan C0 dan C1 yang telah dicerminkan dari sumber ke sendi target
2850
+ function JointMath.mirrorJoint(sourceJoint: JointInstance, targetJoint: JointInstance): boolean
2851
+ if not sourceJoint or not targetJoint then return false end
2852
+ targetJoint.C0 = JointMath.mirrorCFrame(sourceJoint.C0)
2853
+ targetJoint.C1 = JointMath.mirrorCFrame(sourceJoint.C1)
2854
+ return true
2855
+ end
2856
+
2857
+ -- ── 9. Dapatkan Posisi Poros di Dunia ─────────────────────────────────────────
2858
+ function JointMath.getJointWorldPosition(joint: JointInstance): Vector3?
2859
+ local part0 = joint.Part0
2860
+ if not part0 then return nil end
2861
+ return (part0.CFrame * joint.C0).Position
2862
+ end
2863
+
2864
+ return JointMath
2865
+
2866
+ ]]></ProtectedString>
2867
+ </Properties>
2868
+ </Item>
2869
+ <Item class="ModuleScript" referent="RBX_nv_kinematics">
2870
+ <Properties>
2871
+ <BinaryString name="AttributesSerialize"></BinaryString>
2872
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
2873
+ <bool name="DefinesCapabilities">false</bool>
2874
+ <bool name="Disabled">false</bool>
2875
+ <Content name="LinkedSource"><null></null></Content>
2876
+ <string name="Name">nv_kinematics</string>
2877
+ <int64 name="SourceAssetId">-1</int64>
2878
+ <BinaryString name="Tags"></BinaryString>
2879
+ <ProtectedString name="Source"><![CDATA[--!strict
2880
+ -- ══════════════════════════════════════════════════════════════════════
2881
+ -- NVStudio Subsystem: nv_kinematics
2882
+ -- Deskripsi: Mesin Kinematika Analitis 2-Link LimbIK & Abstraksi R6/R15
2883
+ -- Menyelesaikan posisi siku/lutut menggunakan Hukum Cosinus O(1) tertutup,
2884
+ -- ekstensi SoftIK anti-hentakan kaku, dan stabilisasi rotasi leher/kaki.
2885
+ -- ══════════════════════════════════════════════════════════════════════
2886
+
2887
+ local Kinematics = {}
2888
+ local EPSILON = 1e-6
2889
+
2890
+ -- ── 1. SoftIK Algorithm ───────────────────────────────────────────────────────
2891
+ local function softDistance(dist: number, reach: number, softPercent: number?): number
2892
+ if dist <= 0 or reach <= 0 then return 0 end
2893
+ local pct = math.clamp(softPercent or 90, 0, 100) / 100
2894
+ local onset = reach * pct
2895
+ if dist <= onset then
2896
+ return dist
2897
+ end
2898
+ local zone = reach - onset
2899
+ if zone <= 0 then
2900
+ return math.min(dist, reach)
2901
+ end
2902
+ local x = (dist - onset) / zone
2903
+ local eased = x < 1e-4 and (x * (1 - x * 0.5)) or (1 - math.exp(-x))
2904
+ return math.min(dist, reach, onset + zone * eased)
2905
+ end
2906
+
2907
+ -- ── 2. Rotation Between Two Vectors ───────────────────────────────────────────
2908
+ function Kinematics.rotationBetween(from: Vector3, to: Vector3): CFrame
2909
+ if from.Magnitude < EPSILON or to.Magnitude < EPSILON then
2910
+ return CFrame.identity
2911
+ end
2912
+ from = from.Unit
2913
+ to = to.Unit
2914
+ local dot = math.clamp(from:Dot(to), -1, 1)
2915
+ local cross = from:Cross(to)
2916
+ if cross.Magnitude < EPSILON then
2917
+ if dot > 0 then
2918
+ return CFrame.identity
2919
+ end
2920
+ local basis = math.abs(from.X) < 0.8 and Vector3.xAxis or Vector3.yAxis
2921
+ return CFrame.fromAxisAngle(from:Cross(basis).Unit, math.pi)
2922
+ end
2923
+ return CFrame.fromAxisAngle(cross.Unit, math.atan2(cross.Magnitude, dot))
2924
+ end
2925
+
2926
+ -- ── 3. Analytical 2-Bone Limb Solver (Law of Cosines) ─────────────────────────
2927
+ function Kinematics.solveTwoLink(
2928
+ root: Vector3,
2929
+ middle: Vector3,
2930
+ tip: Vector3,
2931
+ goal: Vector3,
2932
+ pole: Vector3?,
2933
+ softPercent: number?
2934
+ ): (Vector3, Vector3)
2935
+ local upperLen = (middle - root).Magnitude
2936
+ local lowerLen = (tip - middle).Magnitude
2937
+ if upperLen < EPSILON or lowerLen < EPSILON then
2938
+ return middle, tip
2939
+ end
2940
+
2941
+ local delta = goal - root
2942
+ local rawDist = delta.Magnitude
2943
+ local dir = rawDist > EPSILON and delta.Unit or (tip - root).Unit
2944
+ local reach = upperLen + lowerLen
2945
+
2946
+ -- Soften distance
2947
+ local softened = softDistance(rawDist, reach, softPercent or 90)
2948
+ local dist = math.clamp(softened, math.max(math.abs(upperLen - lowerLen), EPSILON), reach - 1e-4)
2949
+
2950
+ -- Bend direction towards pole
2951
+ local polePos = pole or (middle + Vector3.new(0, 0, -1))
2952
+ local bend = polePos - root
2953
+ bend = bend - dir * bend:Dot(dir)
2954
+ if bend.Magnitude < EPSILON then
2955
+ local basis = math.abs(dir.X) < 0.8 and Vector3.xAxis or Vector3.yAxis
2956
+ bend = basis - dir * basis:Dot(dir)
2957
+ end
2958
+ bend = bend.Unit
2959
+
2960
+ -- Law of Cosines: Upper arm projection along direction vector
2961
+ local along = (upperLen * upperLen - lowerLen * lowerLen + dist * dist) / (2 * dist)
2962
+ local heightSq = upperLen * upperLen - along * along
2963
+ local height = math.sqrt(math.max(0, heightSq))
2964
+
2965
+ local solvedMiddle = root + dir * along + bend * height
2966
+ local solvedTip = root + dir * dist
2967
+ return solvedMiddle, solvedTip
2968
+ end
2969
+
2970
+ -- ── 4. Rig Discovery & Abstraction (R6 & R15) ──────────────────────────────────
2971
+ export type RigInfo = {
2972
+ model: Model,
2973
+ isR15: boolean,
2974
+ rootPart: BasePart,
2975
+ motors: { [string]: Motor6D },
2976
+ parts: { [string]: BasePart }
2977
+ }
2978
+
2979
+ function Kinematics.inspectRig(model: Model): RigInfo?
2980
+ if not model or not model:IsA("Model") then return nil end
2981
+ local rootPart = model:FindFirstChild("HumanoidRootPart") :: BasePart?
2982
+ if not rootPart then
2983
+ rootPart = model.PrimaryPart or model:FindFirstChildWhichIsA("BasePart")
2984
+ end
2985
+ if not rootPart then return nil end
2986
+
2987
+ local humanoid = model:FindFirstChildOfClass("Humanoid")
2988
+ local isR15 = humanoid and (humanoid.RigType == Enum.HumanoidRigType.R15) or false
2989
+ if not humanoid then
2990
+ isR15 = model:FindFirstChild("UpperTorso") ~= nil
2991
+ end
2992
+
2993
+ local motors: { [string]: Motor6D } = {}
2994
+ local parts: { [string]: BasePart } = {}
2995
+
2996
+ for _, desc in ipairs(model:GetDescendants()) do
2997
+ if desc:IsA("Motor6D") then
2998
+ motors[desc.Name] = desc
2999
+ elseif desc:IsA("BasePart") then
3000
+ parts[desc.Name] = desc
3001
+ end
3002
+ end
3003
+
3004
+ return {
3005
+ model = model,
3006
+ isR15 = isR15,
3007
+ rootPart = rootPart,
3008
+ motors = motors,
3009
+ parts = parts
3010
+ }
3011
+ end
3012
+
3013
+ -- ── 5. Solve Limb IK on R15 / R6 Rig ──────────────────────────────────────────
3014
+ function Kinematics.solveLimb(
3015
+ rigInfo: RigInfo,
3016
+ limbName: string, -- "LeftArm" | "RightArm" | "LeftLeg" | "RightLeg"
3017
+ targetWorldPos: Vector3,
3018
+ poleWorldPos: Vector3?,
3019
+ applyToRig: boolean?
3020
+ ): { [string]: CFrame }?
3021
+ local m = rigInfo.motors
3022
+ local p = rigInfo.parts
3023
+
3024
+ if rigInfo.isR15 then
3025
+ local upperMotorName, lowerMotorName, endMotorName
3026
+ local upperPartName, lowerPartName, endPartName
3027
+ local defaultPoleOffset = Vector3.new(0, 0, -2)
3028
+
3029
+ if limbName == "LeftArm" then
3030
+ upperMotorName, lowerMotorName, endMotorName = "LeftShoulder", "LeftElbow", "LeftWrist"
3031
+ upperPartName, lowerPartName, endPartName = "LeftUpperArm", "LeftLowerArm", "LeftHand"
3032
+ defaultPoleOffset = Vector3.new(-1, 0, -2)
3033
+ elseif limbName == "RightArm" then
3034
+ upperMotorName, lowerMotorName, endMotorName = "RightShoulder", "RightElbow", "RightWrist"
3035
+ upperPartName, lowerPartName, endPartName = "RightUpperArm", "RightLowerArm", "RightHand"
3036
+ defaultPoleOffset = Vector3.new(1, 0, -2)
3037
+ elseif limbName == "LeftLeg" then
3038
+ upperMotorName, lowerMotorName, endMotorName = "LeftHip", "LeftKnee", "LeftAnkle"
3039
+ upperPartName, lowerPartName, endPartName = "LeftUpperLeg", "LeftLowerLeg", "LeftFoot"
3040
+ defaultPoleOffset = Vector3.new(0, 0, 2)
3041
+ elseif limbName == "RightLeg" then
3042
+ upperMotorName, lowerMotorName, endMotorName = "RightHip", "RightKnee", "RightAnkle"
3043
+ upperPartName, lowerPartName, endPartName = "RightUpperLeg", "RightLowerLeg", "RightFoot"
3044
+ defaultPoleOffset = Vector3.new(0, 0, 2)
3045
+ else
3046
+ return nil
3047
+ end
3048
+
3049
+ local upperMotor = m[upperMotorName]
3050
+ local lowerMotor = m[lowerMotorName]
3051
+ local upperPart = p[upperPartName]
3052
+ local lowerPart = p[lowerPartName]
3053
+ local endPart = p[endPartName]
3054
+
3055
+ if not upperMotor or not lowerMotor or not upperPart or not lowerPart or not endPart then
3056
+ return nil
3057
+ end
3058
+
3059
+ local rootPos = upperPart.Position
3060
+ local midPos = lowerPart.Position
3061
+ local tipPos = endPart.Position
3062
+ local pole = poleWorldPos or (midPos + defaultPoleOffset)
3063
+
3064
+ local solvedMid, solvedTip = Kinematics.solveTwoLink(rootPos, midPos, tipPos, targetWorldPos, pole, 90)
3065
+
3066
+ -- Hitung C0 baru untuk upper dan lower
3067
+ local upperParentCF = upperMotor.Part0.CFrame
3068
+ local upperGoalWorldCF = CFrame.lookAt(rootPos, solvedMid, pole - rootPos)
3069
+ local upperC0 = upperParentCF:Inverse() * upperGoalWorldCF * upperMotor.C1
3070
+
3071
+ local lowerParentCF = solvedMid ~= rootPos and CFrame.lookAt(rootPos, solvedMid) or upperPart.CFrame
3072
+ local lowerGoalWorldCF = CFrame.lookAt(solvedMid, solvedTip, pole - solvedMid)
3073
+ local lowerC0 = lowerParentCF:Inverse() * lowerGoalWorldCF * lowerMotor.C1
3074
+
3075
+ if applyToRig then
3076
+ upperMotor.C0 = upperC0
3077
+ lowerMotor.C0 = lowerC0
3078
+ end
3079
+
3080
+ return {
3081
+ [upperMotorName] = upperC0,
3082
+ [lowerMotorName] = lowerC0
3083
+ }
3084
+ else
3085
+ -- Mode R6: 1 bone per limb, arahkan ke targetWorldPos
3086
+ local motorName, partName
3087
+ if limbName == "LeftArm" then motorName, partName = "Left Shoulder", "Left Arm"
3088
+ elseif limbName == "RightArm" then motorName, partName = "Right Shoulder", "Right Arm"
3089
+ elseif limbName == "LeftLeg" then motorName, partName = "Left Hip", "Left Leg"
3090
+ elseif limbName == "RightLeg" then motorName, partName = "Right Hip", "Right Leg"
3091
+ else return nil end
3092
+
3093
+ local motor = m[motorName]
3094
+ local part = p[partName]
3095
+ if not motor or not part or not motor.Part0 then return nil end
3096
+
3097
+ local rootPos = part.Position
3098
+ local goalCF = CFrame.lookAt(rootPos, targetWorldPos)
3099
+ local c0 = motor.Part0.CFrame:Inverse() * goalCF * motor.C1
3100
+
3101
+ if applyToRig then
3102
+ motor.C0 = c0
3103
+ end
3104
+ return { [motorName] = c0 }
3105
+ end
3106
+ end
3107
+
3108
+ -- ── 6. Head Look-At Stabilizer ────────────────────────────────────────────────
3109
+ function Kinematics.solveLookAt(rigInfo: RigInfo, targetWorldPos: Vector3, applyToRig: boolean?): CFrame?
3110
+ local neckMotor = rigInfo.motors["Neck"]
3111
+ local headPart = rigInfo.parts["Head"]
3112
+ if not neckMotor or not headPart or not neckMotor.Part0 then return nil end
3113
+
3114
+ local headPos = headPart.Position
3115
+ local lookDir = (targetWorldPos - headPos).Unit
3116
+ local targetWorldCF = CFrame.lookAt(headPos, headPos + lookDir)
3117
+ local neckC0 = neckMotor.Part0.CFrame:Inverse() * targetWorldCF * neckMotor.C1
3118
+
3119
+ if applyToRig then
3120
+ neckMotor.C0 = neckC0
3121
+ end
3122
+ return neckC0
3123
+ end
3124
+
3125
+ return Kinematics
3126
+
3127
+ ]]></ProtectedString>
3128
+ </Properties>
3129
+ </Item>
3130
+ <Item class="ModuleScript" referent="RBX_nv_motion_generator">
3131
+ <Properties>
3132
+ <BinaryString name="AttributesSerialize"></BinaryString>
3133
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
3134
+ <bool name="DefinesCapabilities">false</bool>
3135
+ <bool name="Disabled">false</bool>
3136
+ <Content name="LinkedSource"><null></null></Content>
3137
+ <string name="Name">nv_motion_generator</string>
3138
+ <int64 name="SourceAssetId">-1</int64>
3139
+ <BinaryString name="Tags"></BinaryString>
3140
+ <ProtectedString name="Source"><![CDATA[--!strict
3141
+ -- ══════════════════════════════════════════════════════════════════════
3142
+ -- NVStudio Subsystem: nv_motion_generator
3143
+ -- Deskripsi: Generator Gerakan Karakter Alami (Human-like Motion Synthesis)
3144
+ -- Menghasilkan pose kunci realistis (Anticipation, Follow-through, Easing)
3145
+ -- untuk berjalan, berlari, tebasan pedang, tinju, idle, dan lompat.
3146
+ -- ══════════════════════════════════════════════════════════════════════
3147
+
3148
+ local curvesMod = script.Parent:WaitForChild("nv_curves")
3149
+ local Curves = require(curvesMod)
3150
+ local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
3151
+ local Kinematics = require(kinematicsMod)
3152
+
3153
+ local MotionGenerator = {}
3154
+
3155
+ export type KeyframeData = {
3156
+ time: number, -- 0.0 to duration
3157
+ poses: { [string]: CFrame } -- motorName -> C0 CFrame
3158
+ }
3159
+
3160
+ -- ── Helper Angles ─────────────────────────────────────────────────────────────
3161
+ local function anglesDeg(x: number, y: number, z: number): CFrame
3162
+ return CFrame.Angles(math.rad(x), math.rad(y), math.rad(z))
3163
+ end
3164
+
3165
+ -- ── 1. Generator: Walk Cycle ──────────────────────────────────────────────────
3166
+ function MotionGenerator.generateWalkCycle(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
3167
+ local opts = options or {}
3168
+ local duration = tonumber(opts.duration) or 1.0
3169
+ local intensity = tonumber(opts.intensity) or 1.0
3170
+ local isR15 = rigInfo.isR15
3171
+
3172
+ -- Base offsets per motor
3173
+ local m = rigInfo.motors
3174
+ local defaultC0 = {}
3175
+ for name, motor in pairs(m) do
3176
+ defaultC0[name] = motor.C0
3177
+ end
3178
+
3179
+ local legAngle = 26 * intensity
3180
+ local armAngle = 22 * intensity
3181
+ local torsoBob = 0.12 * intensity
3182
+ local torsoTwist = 6 * intensity
3183
+
3184
+ local kfTimes = { 0.0, 0.25, 0.50, 0.75, 1.0 }
3185
+ local keyframes: { KeyframeData } = {}
3186
+
3187
+ for _, tFrac in ipairs(kfTimes) do
3188
+ local tSec = tFrac * duration
3189
+ local poses: { [string]: CFrame } = {}
3190
+
3191
+ if isR15 then
3192
+ local legPhase = math.sin(tFrac * math.pi * 2)
3193
+ local armPhase = -legPhase
3194
+
3195
+ -- Torso movement (vertical bounce on each step)
3196
+ local bobY = math.abs(math.cos(tFrac * math.pi * 2)) * torsoBob
3197
+ local rootC0 = defaultC0["Root"] or CFrame.identity
3198
+ poses["Root"] = rootC0 * CFrame.new(0, -bobY, 0) * anglesDeg(4, legPhase * torsoTwist, 0)
3199
+
3200
+ -- Left Leg & Right Leg
3201
+ local lHip = defaultC0["LeftHip"] or CFrame.identity
3202
+ local rHip = defaultC0["RightHip"] or CFrame.identity
3203
+ local lKnee = defaultC0["LeftKnee"] or CFrame.identity
3204
+ local rKnee = defaultC0["RightKnee"] or CFrame.identity
3205
+
3206
+ poses["LeftHip"] = lHip * anglesDeg(legPhase * legAngle, 0, 0)
3207
+ poses["RightHip"] = rHip * anglesDeg(-legPhase * legAngle, 0, 0)
3208
+
3209
+ -- Knee bends when leg swings back
3210
+ local lKneeBend = math.clamp(-legPhase * legAngle * 1.5, 0, 45)
3211
+ local rKneeBend = math.clamp(legPhase * legAngle * 1.5, 0, 45)
3212
+ poses["LeftKnee"] = lKnee * anglesDeg(lKneeBend, 0, 0)
3213
+ poses["RightKnee"] = rKnee * anglesDeg(rKneeBend, 0, 0)
3214
+
3215
+ -- Left Arm & Right Arm (opposite to legs)
3216
+ local lShoulder = defaultC0["LeftShoulder"] or CFrame.identity
3217
+ local rShoulder = defaultC0["RightShoulder"] or CFrame.identity
3218
+ poses["LeftShoulder"] = lShoulder * anglesDeg(armPhase * armAngle, 0, -4)
3219
+ poses["RightShoulder"] = rShoulder * anglesDeg(-armPhase * armAngle, 0, 4)
3220
+ else
3221
+ -- R6 Rig
3222
+ local legPhase = math.sin(tFrac * math.pi * 2)
3223
+ local armPhase = -legPhase
3224
+
3225
+ local lShoulder = defaultC0["Left Shoulder"] or CFrame.identity
3226
+ local rShoulder = defaultC0["Right Shoulder"] or CFrame.identity
3227
+ local lHip = defaultC0["Left Hip"] or CFrame.identity
3228
+ local rHip = defaultC0["Right Hip"] or CFrame.identity
3229
+
3230
+ poses["Left Shoulder"] = lShoulder * anglesDeg(armPhase * armAngle, 0, 0)
3231
+ poses["Right Shoulder"] = rShoulder * anglesDeg(-armPhase * armAngle, 0, 0)
3232
+ poses["Left Hip"] = lHip * anglesDeg(legPhase * legAngle, 0, 0)
3233
+ poses["Right Hip"] = rHip * anglesDeg(-legPhase * legAngle, 0, 0)
3234
+ end
3235
+
3236
+ table.insert(keyframes, { time = tSec, poses = poses })
3237
+ end
3238
+
3239
+ return keyframes
3240
+ end
3241
+
3242
+ -- ── 2. Generator: Melee Slash (Combat Attack) ─────────────────────────────────
3243
+ function MotionGenerator.generateMeleeSlash(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
3244
+ local opts = options or {}
3245
+ local duration = tonumber(opts.duration) or 0.8
3246
+ local isR15 = rigInfo.isR15
3247
+
3248
+ local defaultC0 = {}
3249
+ for name, motor in pairs(rigInfo.motors) do
3250
+ defaultC0[name] = motor.C0
3251
+ end
3252
+
3253
+ -- Keyframes: 0 (Ready), 0.25 (Wind-up Anticipation), 0.50 (Slash Snap), 0.75 (Follow-Through), 1.0 (Recover)
3254
+ local points = {
3255
+ {
3256
+ t = 0.0,
3257
+ armRot = anglesDeg(10, 0, 5),
3258
+ torsoRot = anglesDeg(0, 0, 0),
3259
+ lArmRot = anglesDeg(5, 0, -5)
3260
+ },
3261
+ {
3262
+ t = 0.25, -- Wind-up
3263
+ armRot = anglesDeg(-110, -35, 40),
3264
+ torsoRot = anglesDeg(-5, -25, 0),
3265
+ lArmRot = anglesDeg(20, 15, -15)
3266
+ },
3267
+ {
3268
+ t = 0.50, -- Slash Strike
3269
+ armRot = anglesDeg(65, 30, -25),
3270
+ torsoRot = anglesDeg(10, 35, 0),
3271
+ lArmRot = anglesDeg(-25, -10, -20)
3272
+ },
3273
+ {
3274
+ t = 0.75, -- Follow-through overshoot
3275
+ armRot = anglesDeg(75, 40, -30),
3276
+ torsoRot = anglesDeg(8, 40, 0),
3277
+ lArmRot = anglesDeg(-15, -5, -15)
3278
+ },
3279
+ {
3280
+ t = 1.0, -- Return
3281
+ armRot = anglesDeg(10, 0, 5),
3282
+ torsoRot = anglesDeg(0, 0, 0),
3283
+ lArmRot = anglesDeg(5, 0, -5)
3284
+ }
3285
+ }
3286
+
3287
+ local keyframes: { KeyframeData } = {}
3288
+ for _, pt in ipairs(points) do
3289
+ local poses: { [string]: CFrame } = {}
3290
+ if isR15 then
3291
+ poses["RightShoulder"] = (defaultC0["RightShoulder"] or CFrame.identity) * pt.armRot
3292
+ poses["LeftShoulder"] = (defaultC0["LeftShoulder"] or CFrame.identity) * pt.lArmRot
3293
+ poses["Waist"] = (defaultC0["Waist"] or CFrame.identity) * pt.torsoRot
3294
+ else
3295
+ poses["Right Shoulder"] = (defaultC0["Right Shoulder"] or CFrame.identity) * pt.armRot
3296
+ poses["Left Shoulder"] = (defaultC0["Left Shoulder"] or CFrame.identity) * pt.lArmRot
3297
+ poses["RootJoint"] = (defaultC0["RootJoint"] or CFrame.identity) * pt.torsoRot
3298
+ end
3299
+ table.insert(keyframes, { time = pt.t * duration, poses = poses })
3300
+ end
3301
+
3302
+ return keyframes
3303
+ end
3304
+
3305
+ -- ── 3. Generator: Punch Combo ─────────────────────────────────────────────────
3306
+ function MotionGenerator.generatePunch(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
3307
+ local opts = options or {}
3308
+ local duration = tonumber(opts.duration) or 0.6
3309
+ local isR15 = rigInfo.isR15
3310
+
3311
+ local defaultC0 = {}
3312
+ for name, motor in pairs(rigInfo.motors) do
3313
+ defaultC0[name] = motor.C0
3314
+ end
3315
+
3316
+ local points = {
3317
+ { t = 0.0, rArm = anglesDeg(0, 0, 0), torso = anglesDeg(0, 0, 0) },
3318
+ { t = 0.2, rArm = anglesDeg(-20, -10, 15), torso = anglesDeg(0, -15, 0) }, -- Coil back
3319
+ { t = 0.45, rArm = anglesDeg(90, 10, -5), torso = anglesDeg(8, 25, 0) }, -- Thrust forward
3320
+ { t = 0.7, rArm = anglesDeg(70, 5, -2), torso = anglesDeg(4, 15, 0) }, -- Recoil
3321
+ { t = 1.0, rArm = anglesDeg(0, 0, 0), torso = anglesDeg(0, 0, 0) }
3322
+ }
3323
+
3324
+ local keyframes: { KeyframeData } = {}
3325
+ for _, pt in ipairs(points) do
3326
+ local poses: { [string]: CFrame } = {}
3327
+ if isR15 then
3328
+ poses["RightShoulder"] = (defaultC0["RightShoulder"] or CFrame.identity) * pt.rArm
3329
+ poses["Waist"] = (defaultC0["Waist"] or CFrame.identity) * pt.torso
3330
+ else
3331
+ poses["Right Shoulder"] = (defaultC0["Right Shoulder"] or CFrame.identity) * pt.rArm
3332
+ poses["RootJoint"] = (defaultC0["RootJoint"] or CFrame.identity) * pt.torso
3333
+ end
3334
+ table.insert(keyframes, { time = pt.t * duration, poses = poses })
3335
+ end
3336
+
3337
+ return keyframes
3338
+ end
3339
+
3340
+ -- ── 4. Generator: Idle Breathing ──────────────────────────────────────────────
3341
+ function MotionGenerator.generateIdle(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
3342
+ local opts = options or {}
3343
+ local duration = tonumber(opts.duration) or 2.5
3344
+ local isR15 = rigInfo.isR15
3345
+
3346
+ local defaultC0 = {}
3347
+ for name, motor in pairs(rigInfo.motors) do
3348
+ defaultC0[name] = motor.C0
3349
+ end
3350
+
3351
+ local keyframes: { KeyframeData } = {}
3352
+ local steps = 8
3353
+ for i = 0, steps do
3354
+ local frac = i / steps
3355
+ local timeSec = frac * duration
3356
+ local breathCycle = math.sin(frac * math.pi * 2)
3357
+
3358
+ local poses: { [string]: CFrame } = {}
3359
+ if isR15 then
3360
+ poses["Root"] = (defaultC0["Root"] or CFrame.identity) * CFrame.new(0, breathCycle * 0.04, 0)
3361
+ poses["Waist"] = (defaultC0["Waist"] or CFrame.identity) * anglesDeg(breathCycle * 1.5, 0, 0)
3362
+ poses["Neck"] = (defaultC0["Neck"] or CFrame.identity) * anglesDeg(-breathCycle * 1.0, 0, 0)
3363
+ else
3364
+ poses["RootJoint"] = (defaultC0["RootJoint"] or CFrame.identity) * CFrame.new(0, 0, breathCycle * 0.04)
3365
+ poses["Neck"] = (defaultC0["Neck"] or CFrame.identity) * anglesDeg(-breathCycle * 1.2, 0, 0)
3366
+ end
3367
+
3368
+ table.insert(keyframes, { time = timeSec, poses = poses })
3369
+ end
3370
+
3371
+ return keyframes
3372
+ end
3373
+
3374
+ -- ── 5. Dense Sampler: Interpolasi Keyframes Menggunakan SLERP & Easing ────────
3375
+ function MotionGenerator.sampleSequence(
3376
+ keyframes: { KeyframeData },
3377
+ fps: number,
3378
+ easing: string?
3379
+ ): { [number]: { [string]: CFrame } }
3380
+ if #keyframes == 0 then return {} end
3381
+ table.sort(keyframes, function(a, b) return a.time < b.time end)
3382
+
3383
+ local totalDuration = keyframes[#keyframes].time
3384
+ local totalFrames = math.max(1, math.floor(totalDuration * fps))
3385
+ local sampled: { [number]: { [string]: CFrame } } = {}
3386
+
3387
+ for f = 0, totalFrames do
3388
+ local curTime = (f / totalFrames) * totalDuration
3389
+
3390
+ -- Cari segment keyframe [prev, next]
3391
+ local kA = keyframes[1]
3392
+ local kB = keyframes[#keyframes]
3393
+ for i = 1, #keyframes - 1 do
3394
+ if curTime >= keyframes[i].time and curTime <= keyframes[i + 1].time then
3395
+ kA = keyframes[i]
3396
+ kB = keyframes[i + 1]
3397
+ break
3398
+ end
3399
+ end
3400
+
3401
+ local segDuration = kB.time - kA.time
3402
+ local rawAlpha = segDuration > 1e-5 and ((curTime - kA.time) / segDuration) or 0
3403
+ local curvedAlpha = Curves.evaluate(easing or "QuadInOut", rawAlpha)
3404
+
3405
+ local framePoses: { [string]: CFrame } = {}
3406
+ for motorName, cfA in pairs(kA.poses) do
3407
+ local cfB = kB.poses[motorName] or cfA
3408
+ framePoses[motorName] = Curves.slerp(cfA, cfB, curvedAlpha)
3409
+ end
3410
+
3411
+ sampled[f] = framePoses
3412
+ end
3413
+
3414
+ return sampled
3415
+ end
3416
+
3417
+ return MotionGenerator
3418
+
3419
+ ]]></ProtectedString>
3420
+ </Properties>
3421
+ </Item>
3422
+ <Item class="ModuleScript" referent="RBX_nv_rig_dispatcher">
3423
+ <Properties>
3424
+ <BinaryString name="AttributesSerialize"></BinaryString>
3425
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
3426
+ <bool name="DefinesCapabilities">false</bool>
3427
+ <bool name="Disabled">false</bool>
3428
+ <Content name="LinkedSource"><null></null></Content>
3429
+ <string name="Name">nv_rig_dispatcher</string>
3430
+ <int64 name="SourceAssetId">-1</int64>
3431
+ <BinaryString name="Tags"></BinaryString>
3432
+ <ProtectedString name="Source"><![CDATA[--!strict
3433
+ -- ══════════════════════════════════════════════════════════════════════
3434
+ -- NVStudio Subsystem: nv_rig_dispatcher
3435
+ -- Deskripsi: Headless Modular Command Dispatcher untuk NV-Rig Engine
3436
+ -- Mengelola routing perintah rigging dari AI Agent LLM, eksekusi
3437
+ -- deterministik, inspeksi topologi rig, dan diagnostik otomatis.
3438
+ -- ══════════════════════════════════════════════════════════════════════
3439
+
3440
+ local jointMathMod = script.Parent:WaitForChild("nv_joint_math")
3441
+ local JointMath = require(jointMathMod)
3442
+ local autoRiggerMod = script.Parent:WaitForChild("nv_auto_rigger")
3443
+ local AutoRigger = require(autoRiggerMod)
3444
+
3445
+ local Dispatcher = {}
3446
+
3447
+ -- ── 1. Inspeksi & Diagnostik Rig Mendalam (Deep Topological Inspection) ────────
3448
+ function Dispatcher.inspectRig(targetInstance: Instance): { [string]: any }
3449
+ if not targetInstance then
3450
+ return { status = "error", error = "Target instance tidak ditemukan." }
3451
+ end
3452
+
3453
+ local model = targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model")
3454
+ if not model then
3455
+ model = targetInstance:IsA("Folder") and targetInstance or targetInstance:IsA("BasePart") and targetInstance.Parent :: any
3456
+ end
3457
+ if not model then
3458
+ return { status = "error", error = "Target harus berada di dalam Model atau Folder." }
3459
+ end
3460
+
3461
+ local parts: { [string]: { anchored: boolean, size: { number }, mass: number, className: string } } = {}
3462
+ local joints: { any } = {}
3463
+ local parentToChildren: { [string]: { string } } = {}
3464
+ local childToParent: { [string]: string } = {}
3465
+ local rootCandidates: { string } = {}
3466
+ local anchoredCount = 0
3467
+ local totalParts = 0
3468
+
3469
+ for _, desc in ipairs(model:GetDescendants()) do
3470
+ if desc:IsA("BasePart") then
3471
+ totalParts += 1
3472
+ if desc.Anchored then anchoredCount += 1 end
3473
+ parts[desc.Name] = {
3474
+ anchored = desc.Anchored,
3475
+ size = { desc.Size.X, desc.Size.Y, desc.Size.Z },
3476
+ mass = desc:GetMass(),
3477
+ className = desc.ClassName
3478
+ }
3479
+ elseif desc:IsA("Motor6D") or desc:IsA("Weld") then
3480
+ local p0Name = desc.Part0 and desc.Part0.Name or "nil"
3481
+ local p1Name = desc.Part1 and desc.Part1.Name or "nil"
3482
+ local pivotWorld = JointMath.getJointWorldPosition(desc)
3483
+
3484
+ table.insert(joints, {
3485
+ name = desc.Name,
3486
+ type = desc.ClassName,
3487
+ part0 = p0Name,
3488
+ part1 = p1Name,
3489
+ parent = desc.Parent and desc.Parent.Name or "nil",
3490
+ pivotWorld = pivotWorld and { pivotWorld.X, pivotWorld.Y, pivotWorld.Z } or nil,
3491
+ c0 = { desc.C0:GetComponents() },
3492
+ c1 = { desc.C1:GetComponents() }
3493
+ })
3494
+
3495
+ if p0Name ~= "nil" and p1Name ~= "nil" then
3496
+ if not parentToChildren[p0Name] then parentToChildren[p0Name] = {} end
3497
+ table.insert(parentToChildren[p0Name], p1Name)
3498
+ childToParent[p1Name] = p0Name
3499
+ end
3500
+ end
3501
+ end
3502
+
3503
+ -- Deteksi Root Part (Part yang tidak memiliki Part0 di atasnya)
3504
+ for partName, _ in pairs(parts) do
3505
+ if not childToParent[partName] then
3506
+ table.insert(rootCandidates, partName)
3507
+ end
3508
+ end
3509
+
3510
+ -- Deteksi Loop / Circular Dependency dalam hierarki
3511
+ local hasCycle = false
3512
+ local cyclePath = ""
3513
+ local visited: { [string]: boolean } = {}
3514
+ local recStack: { [string]: boolean } = {}
3515
+
3516
+ local function checkCycle(node: string, path: string): boolean
3517
+ visited[node] = true
3518
+ recStack[node] = true
3519
+ local children = parentToChildren[node] or {}
3520
+ for _, child in ipairs(children) do
3521
+ if not visited[child] then
3522
+ if checkCycle(child, path .. " -> " .. child) then return true end
3523
+ elseif recStack[child] then
3524
+ cyclePath = path .. " -> " .. child
3525
+ return true
3526
+ end
3527
+ end
3528
+ recStack[node] = false
3529
+ return false
3530
+ end
3531
+
3532
+ for _, root in ipairs(rootCandidates) do
3533
+ if checkCycle(root, root) then
3534
+ hasCycle = true
3535
+ break
3536
+ end
3537
+ end
3538
+
3539
+ local humanoid = model:FindFirstChildOfClass("Humanoid")
3540
+ local rigType = humanoid and (humanoid.RigType == Enum.HumanoidRigType.R15 and "R15" or "R6") or "Custom"
3541
+
3542
+ local warnings: { string } = {}
3543
+ if hasCycle then
3544
+ table.insert(warnings, "Ditemukan siklus tertutup (Circular Dependency): " .. cyclePath)
3545
+ end
3546
+ if anchoredCount > 1 then
3547
+ table.insert(warnings, string.format("Ada %d part yang masih 'Anchored = true'. Karakter mungkin tidak dapat bergerak/beranimasi.", anchoredCount))
3548
+ end
3549
+ if #rootCandidates > 1 then
3550
+ table.insert(warnings, "Ditemukan lebih dari 1 calon root part: " .. table.concat(rootCandidates, ", "))
3551
+ end
3552
+
3553
+ return {
3554
+ status = "success",
3555
+ modelName = model.Name,
3556
+ rigType = rigType,
3557
+ totalParts = totalParts,
3558
+ totalJoints = #joints,
3559
+ rootParts = rootCandidates,
3560
+ hasCycle = hasCycle,
3561
+ warnings = warnings,
3562
+ joints = joints,
3563
+ parts = parts
3564
+ }
3565
+ end
3566
+
3567
+ -- ── 2. Buat Sambungan Sendi Baru (Create Joint / Weld) ─────────────────────────
3568
+ function Dispatcher.createJoint(targetModel: Instance, params: { [string]: any }): { [string]: any }
3569
+ local part0Name = params.part0
3570
+ local part1Name = params.part1
3571
+ local jointType = params.joint_type or "Motor6D"
3572
+ local jointName = params.name
3573
+
3574
+ if not part0Name or not part1Name then
3575
+ return { status = "error", error = "Parameter 'part0' dan 'part1' wajib disertakan." }
3576
+ end
3577
+
3578
+ local part0 = targetModel:FindFirstChild(part0Name, true) :: BasePart?
3579
+ local part1 = targetModel:FindFirstChild(part1Name, true) :: BasePart?
3580
+
3581
+ if not part0 or not part0:IsA("BasePart") then
3582
+ return { status = "error", error = "Part0 '" .. tostring(part0Name) .. "' tidak ditemukan." }
3583
+ end
3584
+ if not part1 or not part1:IsA("BasePart") then
3585
+ return { status = "error", error = "Part1 '" .. tostring(part1Name) .. "' tidak ditemukan." }
3586
+ end
3587
+
3588
+ part1.Anchored = false
3589
+ local joint: JointInstance
3590
+ if jointType == "Weld" then
3591
+ joint = Instance.new("Weld")
3592
+ else
3593
+ joint = Instance.new("Motor6D")
3594
+ end
3595
+
3596
+ joint.Name = jointName or (jointType == "Motor6D" and part1.Name or (part0.Name .. "_" .. part1.Name))
3597
+ joint.Part0 = part0
3598
+ joint.Part1 = part1
3599
+ local c0, c1 = JointMath.computeInitialOffsets(part0, part1)
3600
+ joint.C0 = c0
3601
+ joint.C1 = c1
3602
+ joint.Parent = part0
3603
+
3604
+ return {
3605
+ status = "success",
3606
+ jointName = joint.Name,
3607
+ jointType = joint.ClassName,
3608
+ part0 = part0.Name,
3609
+ part1 = part1.Name,
3610
+ message = string.format("Berhasil membuat %s '%s' antara '%s' dan '%s'", joint.ClassName, joint.Name, part0.Name, part1.Name)
3611
+ }
3612
+ end
3613
+
3614
+ -- ── 3. Edit Poros Sendi (Offset World / Target Position) ───────────────────────
3615
+ function Dispatcher.editPivot(targetModel: Instance, params: { [string]: any }): { [string]: any }
3616
+ local jointName = params.joint_name or params.name
3617
+ if not jointName then
3618
+ return { status = "error", error = "Parameter 'joint_name' wajib diisi." }
3619
+ end
3620
+
3621
+ local joint: JointInstance? = nil
3622
+ for _, desc in ipairs(targetModel:GetDescendants()) do
3623
+ if (desc:IsA("Motor6D") or desc:IsA("Weld")) and desc.Name == jointName then
3624
+ joint = desc
3625
+ break
3626
+ end
3627
+ end
3628
+
3629
+ if not joint then
3630
+ return { status = "error", error = "Joint '" .. tostring(jointName) .. "' tidak ditemukan pada model." }
3631
+ end
3632
+
3633
+ local success = false
3634
+ if params.delta_world and type(params.delta_world) == "table" and #params.delta_world >= 3 then
3635
+ local d = params.delta_world
3636
+ success = JointMath.offsetPivot(joint, Vector3.new(d[1], d[2], d[3]))
3637
+ elseif params.target_world and type(params.target_world) == "table" and #params.target_world >= 3 then
3638
+ local p = params.target_world
3639
+ success = JointMath.setPivotWorld(joint, Vector3.new(p[1], p[2], p[3]))
3640
+ elseif params.rotate_euler_deg and type(params.rotate_euler_deg) == "table" and #params.rotate_euler_deg >= 3 then
3641
+ local r = params.rotate_euler_deg
3642
+ local rad = Vector3.new(math.rad(r[1]), math.rad(r[2]), math.rad(r[3]))
3643
+ success = JointMath.rotatePivot(joint, rad)
3644
+ elseif params.reset == true then
3645
+ success = JointMath.resetPivot(joint)
3646
+ elseif params.align_to == "Part0" or params.align_to == "Part1" then
3647
+ success = JointMath.alignJoint(joint, params.align_to)
3648
+ else
3649
+ return { status = "error", error = "Tindakan edit pivot tidak spesifik. Berikan delta_world, target_world, rotate_euler_deg, reset, atau align_to." }
3650
+ end
3651
+
3652
+ if not success then
3653
+ return { status = "error", error = "Gagal memodifikasi poros sendi '" .. jointName .. "'." }
3654
+ end
3655
+
3656
+ local newPivot = JointMath.getJointWorldPosition(joint)
3657
+ return {
3658
+ status = "success",
3659
+ jointName = joint.Name,
3660
+ newPivotWorld = newPivot and { newPivot.X, newPivot.Y, newPivot.Z } or nil,
3661
+ newC0 = { joint.C0:GetComponents() },
3662
+ newC1 = { joint.C1:GetComponents() },
3663
+ message = "Poros sendi '" .. joint.Name .. "' berhasil diperbarui tanpa dislokasi part."
3664
+ }
3665
+ end
3666
+
3667
+ -- ── 4. Cerminkan Sendi Simetri Bilateral (Mirror Joints) ──────────────────────
3668
+ function Dispatcher.mirrorJoints(targetModel: Instance, params: { [string]: any }): { [string]: any }
3669
+ local sourceSide = params.source_side or "Left"
3670
+ local targetSide = sourceSide == "Left" and "Right" or "Left"
3671
+
3672
+ local mirroredCount = 0
3673
+ local pairsProcessed: { string } = {}
3674
+
3675
+ for _, desc in ipairs(targetModel:GetDescendants()) do
3676
+ if desc:IsA("Motor6D") or desc:IsA("Weld") then
3677
+ if string.find(desc.Name, sourceSide) then
3678
+ local targetName = string.gsub(desc.Name, sourceSide, targetSide)
3679
+ local targetJoint: JointInstance? = nil
3680
+ for _, d2 in ipairs(targetModel:GetDescendants()) do
3681
+ if (d2:IsA("Motor6D") or d2:IsA("Weld")) and d2.Name == targetName then
3682
+ targetJoint = d2
3683
+ break
3684
+ end
3685
+ end
3686
+
3687
+ if targetJoint then
3688
+ JointMath.mirrorJoint(desc, targetJoint)
3689
+ mirroredCount += 1
3690
+ table.insert(pairsProcessed, desc.Name .. " -> " .. targetName)
3691
+ end
3692
+ end
3693
+ end
3694
+ end
3695
+
3696
+ return {
3697
+ status = "success",
3698
+ sourceSide = sourceSide,
3699
+ targetSide = targetSide,
3700
+ mirroredCount = mirroredCount,
3701
+ pairs = pairsProcessed,
3702
+ message = string.format("Berhasil mencerminkan %d sendi dari %s ke %s", mirroredCount, sourceSide, targetSide)
3703
+ }
3704
+ end
3705
+
3706
+ -- ── 5. Router Eksekusi Utama (Modular Dispatcher) ─────────────────────────────
3707
+ function Dispatcher.dispatch(targetInstance: Instance, action: string, data: { [string]: any }): { [string]: any }
3708
+ if action == "inspect" then
3709
+ return Dispatcher.inspectRig(targetInstance)
3710
+ elseif action == "create_joint" or action == "create" then
3711
+ return Dispatcher.createJoint(targetInstance, data)
3712
+ elseif action == "create_weld" then
3713
+ data.joint_type = "Weld"
3714
+ return Dispatcher.createJoint(targetInstance, data)
3715
+ elseif action == "edit_pivot" or action == "edit" or action == "move_pivot" or action == "rotate_pivot" or action == "reset_pivot" or action == "align_joint" then
3716
+ return Dispatcher.editPivot(targetInstance, data)
3717
+ elseif action == "mirror" or action == "mirror_joints" then
3718
+ return Dispatcher.mirrorJoints(targetInstance, data)
3719
+ elseif action == "auto_rig_r6" or action == "rig_r6" then
3720
+ return AutoRigger.rigR6(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
3721
+ elseif action == "auto_rig_r15" or action == "rig_r15" then
3722
+ return AutoRigger.rigR15(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
3723
+ elseif action == "rig_weapon" or action == "attach_weapon" then
3724
+ local weapon = data.weapon_path and game:GetService("HttpService") -- akan di-resolve oleh caller jika ada path
3725
+ return AutoRigger.rigWeapon(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any, targetInstance, data.target_limb)
3726
+ elseif action == "weld_assembly" or action == "weld_all" then
3727
+ return AutoRigger.weldAssembly(targetInstance, nil)
3728
+ elseif action == "remove_joint" or action == "delete_joint" then
3729
+ local jointName = data.joint_name or data.name
3730
+ local removed = 0
3731
+ for _, desc in ipairs(targetInstance:GetDescendants()) do
3732
+ if (desc:IsA("Motor6D") or desc:IsA("Weld")) and (not jointName or desc.Name == jointName) then
3733
+ if data.reanchor == true and desc.Part1 then
3734
+ desc.Part1.Anchored = true
3735
+ end
3736
+ desc:Destroy()
3737
+ removed += 1
3738
+ end
3739
+ end
3740
+ return { status = "success", removedCount = removed, message = string.format("Berhasil menghapus %d joint", removed) }
3741
+ else
3742
+ return {
3743
+ status = "error",
3744
+ error = "Action tidak dikenal: '" .. tostring(action) .. "'. Pilihan: inspect, create_joint, create_weld, edit_pivot, auto_rig_r6, auto_rig_r15, weld_assembly, mirror_joints, remove_joint."
3745
+ }
3746
+ end
3747
+ end
3748
+
3749
+ return Dispatcher
3750
+
3751
+ ]]></ProtectedString>
3752
+ </Properties>
3753
+ </Item>
3754
+ <Item class="ModuleScript" referent="RBX_nv_vfx_serializer">
3755
+ <Properties>
3756
+ <BinaryString name="AttributesSerialize"></BinaryString>
3757
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
3758
+ <bool name="DefinesCapabilities">false</bool>
3759
+ <bool name="Disabled">false</bool>
3760
+ <Content name="LinkedSource"><null></null></Content>
3761
+ <string name="Name">nv_vfx_serializer</string>
3762
+ <int64 name="SourceAssetId">-1</int64>
3763
+ <BinaryString name="Tags"></BinaryString>
3764
+ <ProtectedString name="Source"><![CDATA[--!strict
3765
+ -- ══════════════════════════════════════════════════════════════════════
3766
+ -- NVStudio Subsystem: nv_vfx_serializer
3767
+ -- Deskripsi: Serializer & Deserializer 1:1 Efek Visual Roblox
3768
+ -- Mengonversi seluruh properti ParticleEmitter, Beam, Trail, Light,
3769
+ -- dan Attachment menjadi tabel data murni (JSON-safe) dan merekonstruksinya
3770
+ -- dengan fidelitas 100% pada proyek Roblox mana saja.
3771
+ -- ══════════════════════════════════════════════════════════════════════
3772
+
3773
+ local Serializer = {}
3774
+
3775
+ -- ── 1. Property Whitelist Per ClassName ───────────────────────────────────────
3776
+ local PROPERTY_WHITELISTS: { [string]: { string } } = {
3777
+ ParticleEmitter = {
3778
+ "Name", "Enabled", "Texture", "Color", "Size", "Transparency",
3779
+ "Acceleration", "Drag", "EmissionDirection", "Lifetime",
3780
+ "LightEmission", "LightInfluence", "Orientation", "Rate",
3781
+ "RotSpeed", "Rotation", "Shape", "ShapeInOut", "ShapePartial",
3782
+ "ShapeStyle", "Speed", "SpreadAngle", "Squash",
3783
+ "FlipbookFramerate", "FlipbookLayout", "FlipbookMode", "FlipbookStartRandom",
3784
+ "ZOffset", "Brightness", "TimeScale", "VelocityInheritance", "WindAffects"
3785
+ },
3786
+ Beam = {
3787
+ "Name", "Enabled", "Attachment0", "Attachment1", "Color",
3788
+ "CurveSize0", "CurveSize1", "FaceCamera", "LightEmission",
3789
+ "LightInfluence", "Segments", "Texture", "TextureLength",
3790
+ "TextureMode", "TextureSpeed", "Transparency", "Width0",
3791
+ "Width1", "ZOffset", "Brightness"
3792
+ },
3793
+ Trail = {
3794
+ "Name", "Enabled", "Attachment0", "Attachment1", "Color",
3795
+ "FaceCamera", "LightEmission", "LightInfluence", "Lifetime",
3796
+ "MaxLength", "MinLength", "Texture", "TextureLength",
3797
+ "TextureMode", "Transparency", "WidthScale", "Brightness"
3798
+ },
3799
+ Attachment = {
3800
+ "Name", "Visible", "CFrame"
3801
+ },
3802
+ PointLight = {
3803
+ "Name", "Enabled", "Brightness", "Color", "Range", "Shadows"
3804
+ },
3805
+ SpotLight = {
3806
+ "Name", "Enabled", "Brightness", "Color", "Range", "Shadows", "Angle", "Face"
3807
+ },
3808
+ SurfaceLight = {
3809
+ "Name", "Enabled", "Brightness", "Color", "Range", "Shadows", "Face"
3810
+ },
3811
+ Highlight = {
3812
+ "Name", "Enabled", "FillColor", "OutlineColor", "FillTransparency", "OutlineTransparency", "DepthMode"
3813
+ }
3814
+ }
3815
+
3816
+ -- ── 2. Helper: Encode Roblox Values ke Pure Table ─────────────────────────────
3817
+ local function encodeValue(val: any, nodeIndexMap: { [Instance]: number }): any
3818
+ local valType = typeof(val)
3819
+
3820
+ if valType == "nil" or valType == "string" or valType == "number" or valType == "boolean" then
3821
+ return val
3822
+ elseif valType == "Vector3" then
3823
+ return { t = "Vector3", v = { val.X, val.Y, val.Z } }
3824
+ elseif valType == "Vector2" then
3825
+ return { t = "Vector2", v = { val.X, val.Y } }
3826
+ elseif valType == "CFrame" then
3827
+ return { t = "CFrame", v = { val:GetComponents() } }
3828
+ elseif valType == "Color3" then
3829
+ return { t = "Color3", v = { val.R, val.G, val.B } }
3830
+ elseif valType == "NumberRange" then
3831
+ return { t = "NumberRange", v = { val.Min, val.Max } }
3832
+ elseif valType == "NumberSequence" then
3833
+ local kps = {}
3834
+ for _, kp in ipairs(val.Keypoints) do
3835
+ table.insert(kps, { kp.Time, kp.Value, kp.Envelope })
3836
+ end
3837
+ return { t = "NumberSequence", v = kps }
3838
+ elseif valType == "ColorSequence" then
3839
+ local kps = {}
3840
+ for _, kp in ipairs(val.Keypoints) do
3841
+ table.insert(kps, {
3842
+ kp.Time,
3843
+ { t = "Color3", v = { kp.Value.R, kp.Value.G, kp.Value.B } }
3844
+ })
3845
+ end
3846
+ return { t = "ColorSequence", v = kps }
3847
+ elseif valType == "EnumItem" then
3848
+ return { t = "EnumItem", e = tostring(val.EnumType), v = val.Name }
3849
+ elseif valType == "Instance" then
3850
+ local idx = nodeIndexMap[val]
3851
+ if idx then
3852
+ return { t = "Ref", v = idx }
3853
+ end
3854
+ return { t = "InstanceName", v = val.Name }
3855
+ end
3856
+
3857
+ return tostring(val)
3858
+ end
3859
+
3860
+ -- ── 3. Helper: Decode Pure Table ke Roblox Values ──────────────────────────────
3861
+ local function decodeValue(val: any, createdNodes: { [number]: Instance }, scale: number?): any
3862
+ if type(val) ~= "table" or not val.t then
3863
+ return val
3864
+ end
3865
+
3866
+ local t = val.t
3867
+ local v = val.v
3868
+ local s = scale or 1
3869
+
3870
+ if t == "Vector3" then
3871
+ return Vector3.new(v[1] * s, v[2] * s, v[3] * s)
3872
+ elseif t == "Vector2" then
3873
+ return Vector2.new(v[1], v[2])
3874
+ elseif t == "CFrame" then
3875
+ local c = v
3876
+ if s ~= 1 then
3877
+ return CFrame.new(c[1] * s, c[2] * s, c[3] * s, c[4], c[5], c[6], c[7], c[8], c[9], c[10], c[11], c[12])
3878
+ end
3879
+ return CFrame.new(table.unpack(c))
3880
+ elseif t == "Color3" then
3881
+ return Color3.new(v[1], v[2], v[3])
3882
+ elseif t == "NumberRange" then
3883
+ local minV = tonumber(v[1]) or 0
3884
+ local maxV = tonumber(v[2]) or minV
3885
+ return NumberRange.new(math.min(minV, maxV), math.max(minV, maxV))
3886
+ elseif t == "NumberSequence" then
3887
+ local kps = {}
3888
+ for _, kp in ipairs(v) do
3889
+ if type(kp) == "table" then
3890
+ local timeVal = math.clamp(tonumber(kp[1]) or 0, 0, 1)
3891
+ local numVal = (tonumber(kp[2]) or 0) * s
3892
+ local envVal = (tonumber(kp[3]) or 0) * s
3893
+ table.insert(kps, NumberSequenceKeypoint.new(timeVal, numVal, envVal))
3894
+ end
3895
+ end
3896
+ if #kps >= 2 then
3897
+ table.sort(kps, function(a, b) return a.Time < b.Time end)
3898
+ if kps[1].Time ~= 0 then
3899
+ kps[1] = NumberSequenceKeypoint.new(0, kps[1].Value, kps[1].Envelope)
3900
+ end
3901
+ if kps[#kps].Time ~= 1 then
3902
+ kps[#kps] = NumberSequenceKeypoint.new(1, kps[#kps].Value, kps[#kps].Envelope)
3903
+ end
3904
+ for i = 2, #kps do
3905
+ if kps[i].Time <= kps[i - 1].Time then
3906
+ local newT = math.min(1.0, kps[i - 1].Time + 0.001)
3907
+ kps[i] = NumberSequenceKeypoint.new(newT, kps[i].Value, kps[i].Envelope)
3908
+ end
3909
+ end
3910
+ return NumberSequence.new(kps)
3911
+ elseif #kps == 1 then
3912
+ return NumberSequence.new(kps[1].Value)
3913
+ end
3914
+ return NumberSequence.new(0)
3915
+ elseif t == "ColorSequence" then
3916
+ local kps = {}
3917
+ for _, kp in ipairs(v) do
3918
+ if type(kp) == "table" and kp[2] then
3919
+ local timeVal = math.clamp(tonumber(kp[1]) or 0, 0, 1)
3920
+ local col = kp[2].v or { 1, 1, 1 }
3921
+ table.insert(kps, ColorSequenceKeypoint.new(timeVal, Color3.new(col[1] or 1, col[2] or 1, col[3] or 1)))
3922
+ end
3923
+ end
3924
+ if #kps >= 2 then
3925
+ table.sort(kps, function(a, b) return a.Time < b.Time end)
3926
+ if kps[1].Time ~= 0 then
3927
+ kps[1] = ColorSequenceKeypoint.new(0, kps[1].Value)
3928
+ end
3929
+ if kps[#kps].Time ~= 1 then
3930
+ kps[#kps] = ColorSequenceKeypoint.new(1, kps[#kps].Value)
3931
+ end
3932
+ for i = 2, #kps do
3933
+ if kps[i].Time <= kps[i - 1].Time then
3934
+ local newT = math.min(1.0, kps[i - 1].Time + 0.001)
3935
+ kps[i] = ColorSequenceKeypoint.new(newT, kps[i].Value)
3936
+ end
3937
+ end
3938
+ return ColorSequence.new(kps)
3939
+ elseif #kps == 1 then
3940
+ return ColorSequence.new(kps[1].Value)
3941
+ end
3942
+ return ColorSequence.new(Color3.new(1, 1, 1))
3943
+ elseif t == "EnumItem" then
3944
+ local enumGroup = Enum[val.e]
3945
+ if enumGroup and enumGroup[v] then
3946
+ return enumGroup[v]
3947
+ end
3948
+ return nil
3949
+ elseif t == "Ref" then
3950
+ return createdNodes[v]
3951
+ end
3952
+
3953
+ return val
3954
+ end
3955
+
3956
+ -- ── 4. Serializer: Mengonversi Instance Hierarchy Menjadi Pure Table ─────────
3957
+ function Serializer.serialize(rootInstance: Instance): { [string]: any }
3958
+ assert(typeof(rootInstance) == "Instance", "Root instance harus berupa Roblox Instance")
3959
+
3960
+ -- Kumpulkan seluruh node VFX yang didukung
3961
+ local rawNodes: { Instance } = {}
3962
+ local nodeIndexMap: { [Instance]: number } = {}
3963
+
3964
+ local function collect(inst: Instance)
3965
+ if PROPERTY_WHITELISTS[inst.ClassName] or inst:IsA("Folder") or inst:IsA("Model") or inst:IsA("Configuration") or inst == rootInstance then
3966
+ table.insert(rawNodes, inst)
3967
+ nodeIndexMap[inst] = #rawNodes
3968
+ end
3969
+ for _, child in ipairs(inst:GetChildren()) do
3970
+ collect(child)
3971
+ end
3972
+ end
3973
+
3974
+ collect(rootInstance)
3975
+
3976
+ local nodes = {}
3977
+ local stats = { emitters = 0, beams = 0, trails = 0, lights = 0 }
3978
+
3979
+ for i, inst in ipairs(rawNodes) do
3980
+ local className = inst.ClassName
3981
+ local parentIndex = nil
3982
+ if inst ~= rootInstance and inst.Parent then
3983
+ parentIndex = nodeIndexMap[inst.Parent]
3984
+ end
3985
+
3986
+ local props = {}
3987
+ local whitelist = PROPERTY_WHITELISTS[className]
3988
+ if whitelist then
3989
+ for _, propName in ipairs(whitelist) do
3990
+ local ok, propVal = pcall(function() return (inst :: any)[propName] end)
3991
+ if ok and propVal ~= nil then
3992
+ props[propName] = encodeValue(propVal, nodeIndexMap)
3993
+ end
3994
+ end
3995
+ else
3996
+ props["Name"] = inst.Name
3997
+ end
3998
+
3999
+ -- Perbarui statistik
4000
+ if className == "ParticleEmitter" then
4001
+ stats.emitters += 1
4002
+ elseif className == "Beam" then
4003
+ stats.beams += 1
4004
+ elseif className == "Trail" then
4005
+ stats.trails += 1
4006
+ elseif className:find("Light") then
4007
+ stats.lights += 1
4008
+ end
4009
+
4010
+ nodes[i] = {
4011
+ class = className,
4012
+ name = inst.Name,
4013
+ parent = parentIndex,
4014
+ properties = props
4015
+ }
4016
+ end
4017
+
4018
+ return {
4019
+ version = 1,
4020
+ rootName = rootInstance.Name,
4021
+ rootClass = rootInstance.ClassName,
4022
+ nodeCount = #nodes,
4023
+ stats = stats,
4024
+ nodes = nodes
4025
+ }
4026
+ end
4027
+
4028
+ -- ── 5. Deserializer: Merekonstruksi Roblox Instances dari Pure Table ───────────
4029
+ function Serializer.deserialize(treeData: { [string]: any }, targetParent: Instance, options: { scale: number?, enabled: boolean? }?): { [string]: any }
4030
+ assert(type(treeData) == "table" and treeData.nodes, "Format data treeData tidak valid")
4031
+ assert(typeof(targetParent) == "Instance", "TargetParent harus berupa Instance")
4032
+
4033
+ local opts = options or {}
4034
+ local scale = tonumber(opts.scale) or 1
4035
+ local createdNodes: { [number]: Instance } = {}
4036
+ local refDeferred: { { inst: Instance, propName: string, refIdx: number } } = {}
4037
+ local errors: { string } = {}
4038
+ local createdCount = 0
4039
+
4040
+ -- Pass 1: Buat semua instance dan tetapkan parent dasar
4041
+ for i, node in ipairs(treeData.nodes) do
4042
+ local ok, inst = pcall(function()
4043
+ -- Jika node pertama adalah root dan bertipe Part/Model pembungkus, kita bisa menaruh isinya langsung
4044
+ if i == 1 and (node.class == "Folder" or node.class == "Model" or node.class == "Configuration") and targetParent:IsA("BasePart") then
4045
+ -- Buat container pembungkus di targetParent
4046
+ local container = Instance.new(node.class)
4047
+ container.Name = node.name or "NV_VFX"
4048
+ container.Parent = targetParent
4049
+ return container
4050
+ elseif i == 1 and (node.class == "ParticleEmitter" or node.class == "Beam" or node.class == "Trail" or node.class == "Attachment") then
4051
+ local newInst = Instance.new(node.class)
4052
+ newInst.Name = node.name or "NV_VFX"
4053
+ newInst.Parent = targetParent
4054
+ return newInst
4055
+ else
4056
+ local newInst = Instance.new(node.class)
4057
+ newInst.Name = node.name or ("Node_" .. tostring(i))
4058
+ return newInst
4059
+ end
4060
+ end)
4061
+
4062
+ if ok and inst then
4063
+ createdNodes[i] = inst
4064
+ createdCount += 1
4065
+ else
4066
+ table.insert(errors, "Gagal membuat instance untuk node " .. tostring(i) .. ": " .. tostring(inst))
4067
+ end
4068
+ end
4069
+
4070
+ -- Pass 2: Hubungkan hierarki Parent
4071
+ for i, node in ipairs(treeData.nodes) do
4072
+ local inst = createdNodes[i]
4073
+ if inst and i > 1 then
4074
+ local parentIdx = node.parent
4075
+ if parentIdx and createdNodes[parentIdx] then
4076
+ inst.Parent = createdNodes[parentIdx]
4077
+ else
4078
+ inst.Parent = createdNodes[1] or targetParent
4079
+ end
4080
+ end
4081
+ end
4082
+
4083
+ -- Pass 3: Terapkan Properti (dengan scaling & deferred Attachment ref)
4084
+ for i, node in ipairs(treeData.nodes) do
4085
+ local inst = createdNodes[i]
4086
+ if inst and node.properties then
4087
+ for propName, propData in pairs(node.properties) do
4088
+ if type(propData) == "table" and propData.t == "Ref" then
4089
+ table.insert(refDeferred, { inst = inst, propName = propName, refIdx = propData.v })
4090
+ else
4091
+ local pScale = (propName == "Size" or propName == "Width0" or propName == "Width1" or propName == "Speed" or propName == "Acceleration") and scale or 1
4092
+ local decoded = decodeValue(propData, createdNodes, pScale)
4093
+ if decoded ~= nil then
4094
+ pcall(function()
4095
+ if propName == "Enabled" and opts.enabled ~= nil then
4096
+ (inst :: any)[propName] = opts.enabled
4097
+ else
4098
+ (inst :: any)[propName] = decoded
4099
+ end
4100
+ end)
4101
+ end
4102
+ end
4103
+ end
4104
+ end
4105
+ end
4106
+
4107
+ -- Pass 4: Hubungkan Deferred Attachment References (Beam / Trail)
4108
+ for _, ref in ipairs(refDeferred) do
4109
+ local targetNode = createdNodes[ref.refIdx]
4110
+ if targetNode and targetNode:IsA("Attachment") then
4111
+ pcall(function()
4112
+ (ref.inst :: any)[ref.propName] = targetNode
4113
+ end)
4114
+ end
4115
+ end
4116
+
4117
+ return {
4118
+ status = #errors == 0 and "success" or "partial",
4119
+ createdCount = createdCount,
4120
+ root = createdNodes[1] or targetParent,
4121
+ errors = errors
4122
+ }
4123
+ end
4124
+
4125
+ return Serializer
4126
+
4127
+ ]]></ProtectedString>
4128
+ </Properties>
4129
+ </Item>
4130
+ <Item class="ModuleScript" referent="RBX_patch_script_source">
4131
+ <Properties>
4132
+ <BinaryString name="AttributesSerialize"></BinaryString>
4133
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
4134
+ <bool name="DefinesCapabilities">false</bool>
4135
+ <bool name="Disabled">false</bool>
4136
+ <Content name="LinkedSource"><null></null></Content>
4137
+ <string name="Name">patch_script_source</string>
4138
+ <int64 name="SourceAssetId">-1</int64>
4139
+ <BinaryString name="Tags"></BinaryString>
4140
+ <ProtectedString name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
4141
+
4142
+ return function(targetInstance, data, ctx, targetPath)
4143
+ if not targetInstance:IsA("LuaSourceContainer") then
4144
+ return { status = "error", error = "Target BUKAN Script (ClassName: " .. targetInstance.ClassName .. ")." }
4145
+ end
4146
+
4147
+ local payload = nil
4148
+ pcall(function()
4149
+ payload = HttpService:JSONDecode(data)
4150
+ end)
4151
+
4152
+ if not payload then
4153
+ return { status = "error", error = "Payload data harus berupa JSON valid." }
4154
+ end
4155
+
4156
+ local currentSource = targetInstance.Source
4157
+ local newSource = currentSource
4158
+ local affectedLines = 0
4159
+
4160
+ -- Mode 1: Search & Replace Exact Chunk
4161
+ if payload.target_content and payload.replacement_content then
4162
+ local targetContent = payload.target_content
4163
+ local replacementContent = payload.replacement_content
4164
+ local allowMultiple = payload.allow_multiple or false
4165
+
4166
+ if targetContent == "" then
4167
+ return { status = "error", error = "target_content tidak boleh berupa string kosong." }
4168
+ end
4169
+
4170
+ -- Escape magic characters for string.gsub if needed, or use string.find manually.
4171
+ -- string.gsub doesn't do plain text replacement directly without escaping.
4172
+ -- We'll write a plain text replace function.
4173
+
4174
+ local function plainReplace(str, pattern, repl, max)
4175
+ local res = ""
4176
+ local startIdx = 1
4177
+ local count = 0
4178
+ while true do
4179
+ if max and count >= max then break end
4180
+ local findStart, findEnd = string.find(str, pattern, startIdx, true) -- true for plain match
4181
+ if not findStart then break end
4182
+
4183
+ res = res .. string.sub(str, startIdx, findStart - 1) .. repl
4184
+ startIdx = findEnd + 1
4185
+ count = count + 1
4186
+ end
4187
+ res = res .. string.sub(str, startIdx)
4188
+ return res, count
4189
+ end
4190
+
4191
+ local function countOccurrences(str, pattern)
4192
+ local count = 0
4193
+ local startIdx = 1
4194
+ while true do
4195
+ local findStart, findEnd = string.find(str, pattern, startIdx, true)
4196
+ if not findStart then break end
4197
+ count = count + 1
4198
+ startIdx = findEnd + 1
4199
+ if count > 1 and not allowMultiple then break end
4200
+ end
4201
+ return count
4202
+ end
4203
+
4204
+ local occurrences = countOccurrences(currentSource, targetContent)
4205
+
4206
+ if occurrences == 0 then
4207
+ return { status = "error", error = "target_content tidak ditemukan di dalam script." }
4208
+ elseif occurrences > 1 and not allowMultiple then
4209
+ return { status = "error", error = "target_content ditemukan lebih dari 1 kali. Tolong sertakan baris konteks lebih banyak agar unik, atau set allow_multiple = true." }
4210
+ end
4211
+
4212
+ newSource, affectedLines = plainReplace(currentSource, targetContent, replacementContent)
4213
+
4214
+ -- Mode 2: Line Range Replace
4215
+ elseif payload.start_line and payload.end_line and payload.replacement_content then
4216
+ local lines = string.split(currentSource, "\n")
4217
+ local startLine = tonumber(payload.start_line)
4218
+ local endLine = tonumber(payload.end_line)
4219
+
4220
+ if not startLine or not endLine or startLine < 1 or endLine > #lines or startLine > endLine then
4221
+ return { status = "error", error = "Range baris tidak valid. Jumlah baris script: " .. #lines }
4222
+ end
4223
+
4224
+ local newLines = {}
4225
+ for i = 1, startLine - 1 do
4226
+ table.insert(newLines, lines[i])
4227
+ end
4228
+
4229
+ table.insert(newLines, payload.replacement_content)
4230
+
4231
+ for i = endLine + 1, #lines do
4232
+ table.insert(newLines, lines[i])
4233
+ end
4234
+
4235
+ newSource = table.concat(newLines, "\n")
4236
+ affectedLines = (endLine - startLine) + 1
4237
+ else
4238
+ return { status = "error", error = "Payload tidak lengkap. Butuh (target_content & replacement_content) atau (start_line, end_line & replacement_content)." }
4239
+ end
4240
+
4241
+ -- Simpan backup snapshot sebelum mengaplikasikan patch
4242
+ if type(ctx.scriptHistory[targetPath]) ~= "table" then
4243
+ ctx.scriptHistory[targetPath] = {}
4244
+ end
4245
+ table.insert(ctx.scriptHistory[targetPath], currentSource)
4246
+ if #ctx.scriptHistory[targetPath] > 15 then table.remove(ctx.scriptHistory[targetPath], 1) end
4247
+
4248
+ -- Terapkan perubahan
4249
+ targetInstance.Source = newSource
4250
+
4251
+ return {
4252
+ status = "success",
4253
+ result = "Patch berhasil diterapkan pada '" .. targetInstance.Name .. "'. Perubahan diaplikasikan.",
4254
+ affected_lines = affectedLines
4255
+ }
4256
+ end
4257
+ ]]></ProtectedString>
4258
+ </Properties>
4259
+ </Item>
4260
+ <Item class="ModuleScript" referent="RBX_preview_asset">
4261
+ <Properties>
4262
+ <BinaryString name="AttributesSerialize"></BinaryString>
4263
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
4264
+ <bool name="DefinesCapabilities">false</bool>
4265
+ <bool name="Disabled">false</bool>
4266
+ <Content name="LinkedSource"><null></null></Content>
4267
+ <string name="Name">preview_asset</string>
4268
+ <int64 name="SourceAssetId">-1</int64>
4269
+ <BinaryString name="Tags"></BinaryString>
4270
+ <ProtectedString name="Source"><![CDATA[--!strict
4271
+ -- ============================================================================
4272
+ -- Skill Module: preview_asset
4273
+ -- Deskripsi: Pratinjau & Pemindaian Keamanan Aset Creator Store Secara Terisolasi
4274
+ -- ============================================================================
4275
+
4276
+ local HttpService = game:GetService("HttpService")
4277
+ local InsertService = game:GetService("InsertService")
1767
4278
 
1768
4279
  local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
1769
4280
 
@@ -1981,6 +4492,104 @@ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: stri
1981
4492
  }
1982
4493
  end
1983
4494
 
4495
+ ]]></ProtectedString>
4496
+ </Properties>
4497
+ </Item>
4498
+ <Item class="ModuleScript" referent="RBX_scan_vfx">
4499
+ <Properties>
4500
+ <BinaryString name="AttributesSerialize"></BinaryString>
4501
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
4502
+ <bool name="DefinesCapabilities">false</bool>
4503
+ <bool name="Disabled">false</bool>
4504
+ <Content name="LinkedSource"><null></null></Content>
4505
+ <string name="Name">scan_vfx</string>
4506
+ <int64 name="SourceAssetId">-1</int64>
4507
+ <BinaryString name="Tags"></BinaryString>
4508
+ <ProtectedString name="Source"><![CDATA[--!strict
4509
+ -- ══════════════════════════════════════════════════════════════════════
4510
+ -- Skill Module: scan_vfx
4511
+ -- Deskripsi: Memindai efek visual (ParticleEmitter, Beam, Trail, Light)
4512
+ -- pada objek target di Roblox Studio dan mengubahnya menjadi tabel murni
4513
+ -- ══════════════════════════════════════════════════════════════════════
4514
+
4515
+ local HttpService = game:GetService("HttpService")
4516
+ local serializerMod = script.Parent:WaitForChild("nv_vfx_serializer")
4517
+ local Serializer = require(serializerMod)
4518
+
4519
+ local function detectCategory(name: string, data: any): string
4520
+ local lower = string.lower(name)
4521
+ if lower:find("slash") or lower:find("blade") or lower:find("sword") or lower:find("cut") then
4522
+ return "Slash"
4523
+ elseif lower:find("explosion") or lower:find("burst") or lower:find("blast") or lower:find("boom") or lower:find("fire") then
4524
+ return "Explosion"
4525
+ elseif lower:find("aura") or lower:find("glow") or lower:find("shield") or lower:find("barrier") then
4526
+ return "Aura"
4527
+ elseif lower:find("laser") or lower:find("beam") or lower:find("ray") then
4528
+ return "Beam"
4529
+ elseif lower:find("magic") or lower:find("spell") or lower:find("rune") or lower:find("circle") or lower:find("portal") then
4530
+ return "Magic"
4531
+ elseif lower:find("hit") or lower:find("impact") or lower:find("spark") or lower:find("clash") then
4532
+ return "Impact"
4533
+ elseif lower:find("projectile") or lower:find("bullet") or lower:find("arrow") or lower:find("missile") then
4534
+ return "Projectile"
4535
+ end
4536
+ return "Ambient"
4537
+ end
4538
+
4539
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
4540
+ if not targetInstance then
4541
+ return { status = "error", error = "Target instance untuk scan_vfx tidak ditemukan: " .. tostring(targetPath) }
4542
+ end
4543
+
4544
+ local parsedData = {}
4545
+ if data and data ~= "" then
4546
+ pcall(function()
4547
+ if type(data) == "table" then
4548
+ parsedData = data
4549
+ else
4550
+ parsedData = HttpService:JSONDecode(tostring(data))
4551
+ end
4552
+ end)
4553
+ end
4554
+
4555
+ local vfxName = parsedData.name or targetInstance.Name or "Unnamed_VFX"
4556
+ local rawCat = parsedData.category or detectCategory(vfxName, parsedData)
4557
+ local category = "Ambient"
4558
+ if type(rawCat) == "string" and #rawCat > 0 then
4559
+ category = string.upper(string.sub(rawCat, 1, 1)) .. string.lower(string.sub(rawCat, 2))
4560
+ end
4561
+ local tags = parsedData.tags or { string.lower(category) }
4562
+
4563
+ local ok, serialized = pcall(function()
4564
+ return Serializer.serialize(targetInstance)
4565
+ end)
4566
+
4567
+ if not ok or not serialized then
4568
+ return { status = "error", error = "Gagal memindai VFX: " .. tostring(serialized) }
4569
+ end
4570
+
4571
+ 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
4572
+ return {
4573
+ status = "error",
4574
+ error = "Objek '" .. targetInstance.Name .. "' tidak memiliki komponen VFX (ParticleEmitter, Beam, Trail, atau Light)."
4575
+ }
4576
+ end
4577
+
4578
+ return {
4579
+ status = "success",
4580
+ result = {
4581
+ name = vfxName,
4582
+ category = category,
4583
+ tags = tags,
4584
+ description = parsedData.description or ("Scanned VFX from " .. targetInstance:GetFullName()),
4585
+ stats = serialized.stats,
4586
+ nodeCount = serialized.nodeCount,
4587
+ rootClass = serialized.rootClass,
4588
+ data = serialized
4589
+ }
4590
+ }
4591
+ end
4592
+
1984
4593
  ]]></ProtectedString>
1985
4594
  </Properties>
1986
4595
  </Item>
@@ -2074,6 +4683,141 @@ end
2074
4683
 
2075
4684
  return { status = "success", matches = matches, count = matchCount, limitReached = (matchCount >= MAX_MATCHES) }
2076
4685
  end
4686
+ ]]></ProtectedString>
4687
+ </Properties>
4688
+ </Item>
4689
+ <Item class="ModuleScript" referent="RBX_scrub_timeline">
4690
+ <Properties>
4691
+ <BinaryString name="AttributesSerialize"></BinaryString>
4692
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
4693
+ <bool name="DefinesCapabilities">false</bool>
4694
+ <bool name="Disabled">false</bool>
4695
+ <Content name="LinkedSource"><null></null></Content>
4696
+ <string name="Name">scrub_timeline</string>
4697
+ <int64 name="SourceAssetId">-1</int64>
4698
+ <BinaryString name="Tags"></BinaryString>
4699
+ <ProtectedString name="Source"><![CDATA[--!strict
4700
+ -- ══════════════════════════════════════════════════════════════════════
4701
+ -- Skill Module: scrub_timeline
4702
+ -- Deskripsi: Headless Timeline Scrubbing & VFX Playhead Controller
4703
+ -- Mengevaluasi pose rig dan memicu burst/scrub VFX pada timestamp spesifik (t)
4704
+ -- secara deterministik langsung di Viewport Roblox Studio.
4705
+ -- ══════════════════════════════════════════════════════════════════════
4706
+
4707
+ local HttpService = game:GetService("HttpService")
4708
+ local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
4709
+ local Kinematics = require(kinematicsMod)
4710
+ local curvesMod = script.Parent:WaitForChild("nv_curves")
4711
+ local Curves = require(curvesMod)
4712
+ local motionMod = script.Parent:WaitForChild("nv_motion_generator")
4713
+ local Motion = require(motionMod)
4714
+
4715
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
4716
+ if not targetInstance then
4717
+ return { status = "error", error = "Target instance untuk scrub_timeline tidak ditemukan: " .. tostring(targetPath) }
4718
+ end
4719
+
4720
+ local payload: { [string]: any } = {}
4721
+ pcall(function()
4722
+ if type(data) == "table" then payload = data
4723
+ else payload = HttpService:JSONDecode(tostring(data)) end
4724
+ end)
4725
+
4726
+ local timePos = tonumber(payload.time) or 0.0
4727
+ local action = payload.action or "melee_slash"
4728
+ local emitVfx = payload.emit_vfx == true or payload.emit == true
4729
+ local emitCount = tonumber(payload.emit_count) or 20
4730
+
4731
+ local rigInfo = Kinematics.inspectRig(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
4732
+ local appliedMotors: { string } = {}
4733
+
4734
+ -- 1. Scrub Rig Joint Poses jika target adalah Rig
4735
+ if rigInfo then
4736
+ local keyframes: { Motion.KeyframeData }
4737
+ if action == "walk_cycle" then
4738
+ keyframes = Motion.generateWalkCycle(rigInfo, payload.parameters)
4739
+ elseif action == "punch" then
4740
+ keyframes = Motion.generatePunch(rigInfo, payload.parameters)
4741
+ elseif action == "idle" or action == "idle_breath" then
4742
+ keyframes = Motion.generateIdle(rigInfo, payload.parameters)
4743
+ else
4744
+ keyframes = Motion.generateMeleeSlash(rigInfo, payload.parameters)
4745
+ end
4746
+
4747
+ if #keyframes > 0 then
4748
+ table.sort(keyframes, function(a, b) return a.time < b.time end)
4749
+ local totalDur = keyframes[#keyframes].time
4750
+ local clampedTime = math.clamp(timePos, 0, totalDur)
4751
+
4752
+ -- Cari segmen [kA, kB]
4753
+ local kA = keyframes[1]
4754
+ local kB = keyframes[#keyframes]
4755
+ for i = 1, #keyframes - 1 do
4756
+ if clampedTime >= keyframes[i].time and clampedTime <= keyframes[i + 1].time then
4757
+ kA = keyframes[i]
4758
+ kB = keyframes[i + 1]
4759
+ break
4760
+ end
4761
+ end
4762
+
4763
+ local segDur = kB.time - kA.time
4764
+ local alpha = segDur > 1e-5 and ((clampedTime - kA.time) / segDur) or 0
4765
+ local curvedAlpha = Curves.evaluate(payload.easing or "QuadInOut", alpha)
4766
+
4767
+ for motorName, cfA in pairs(kA.poses) do
4768
+ local cfB = kB.poses[motorName] or cfA
4769
+ local interpCF = Curves.slerp(cfA, cfB, curvedAlpha)
4770
+ local m = rigInfo.motors[motorName]
4771
+ if m then
4772
+ pcall(function()
4773
+ m.C0 = interpCF
4774
+ table.insert(appliedMotors, motorName)
4775
+ end)
4776
+ end
4777
+ end
4778
+ end
4779
+ end
4780
+
4781
+ -- 2. Scrub & Trigger VFX Emitters
4782
+ local emittersTriggered = 0
4783
+ local beamsToggled = 0
4784
+
4785
+ local function checkVfx(inst: Instance)
4786
+ if inst:IsA("ParticleEmitter") then
4787
+ if emitVfx then
4788
+ pcall(function()
4789
+ inst:Emit(emitCount)
4790
+ emittersTriggered += 1
4791
+ end)
4792
+ end
4793
+ elseif inst:IsA("Beam") or inst:IsA("Trail") then
4794
+ if payload.enabled ~= nil then
4795
+ pcall(function()
4796
+ inst.Enabled = payload.enabled
4797
+ beamsToggled += 1
4798
+ end)
4799
+ end
4800
+ end
4801
+ end
4802
+
4803
+ checkVfx(targetInstance)
4804
+ for _, desc in ipairs(targetInstance:GetDescendants()) do
4805
+ checkVfx(desc)
4806
+ end
4807
+
4808
+ return {
4809
+ status = "success",
4810
+ result = {
4811
+ time = timePos,
4812
+ action = action,
4813
+ motorsPosed = #appliedMotors,
4814
+ emittersTriggered = emittersTriggered,
4815
+ beamsToggled = beamsToggled,
4816
+ message = string.format("Timeline di-scrub ke t=%.2fs (Pose: %d joint, VFX: %d emitter burst)", timePos, #appliedMotors, emittersTriggered)
4817
+ }
4818
+ }
4819
+ end
4820
+
2077
4821
  ]]></ProtectedString>
2078
4822
  </Properties>
2079
4823
  </Item>
@@ -2533,6 +5277,79 @@ return SecurityScanner
2533
5277
  targetInstance[prop] = BrickColor.new(val)
2534
5278
  elseif typeof(currentVal) == "CFrame" and type(val) == "table" then
2535
5279
  targetInstance[prop] = CFrame.new(unpack(val))
5280
+ elseif typeof(currentVal) == "ColorSequence" or (type(val) == "table" and val.t == "ColorSequence") then
5281
+ local kps = {}
5282
+ local rawList = (type(val) == "table" and val.t == "ColorSequence") and val.v or val
5283
+ if type(rawList) == "table" then
5284
+ for _, kp in ipairs(rawList) do
5285
+ local t = kp[1] or 0
5286
+ local col = kp[2]
5287
+ local c3
5288
+ if typeof(col) == "Color3" then
5289
+ c3 = col
5290
+ elseif type(col) == "table" and col.t == "Color3" then
5291
+ c3 = Color3.new(col.v[1], col.v[2], col.v[3])
5292
+ elseif type(col) == "table" and #col >= 3 then
5293
+ 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])
5294
+ else
5295
+ c3 = Color3.new(1, 1, 1)
5296
+ end
5297
+ table.insert(kps, ColorSequenceKeypoint.new(math.clamp(t, 0, 1), c3))
5298
+ end
5299
+ end
5300
+ if #kps >= 2 then
5301
+ table.sort(kps, function(a, b) return a.Time < b.Time end)
5302
+ -- Pastikan keypoint pertama t=0 dan terakhir t=1
5303
+ if kps[1].Time ~= 0 then
5304
+ kps[1] = ColorSequenceKeypoint.new(0, kps[1].Value)
5305
+ end
5306
+ if kps[#kps].Time ~= 1 then
5307
+ kps[#kps] = ColorSequenceKeypoint.new(1, kps[#kps].Value)
5308
+ end
5309
+ -- Pastikan waktu meningkat secara monoton murni
5310
+ for i = 2, #kps do
5311
+ if kps[i].Time <= kps[i - 1].Time then
5312
+ local newT = math.min(1.0, kps[i - 1].Time + 0.001)
5313
+ kps[i] = ColorSequenceKeypoint.new(newT, kps[i].Value)
5314
+ end
5315
+ end
5316
+ targetInstance[prop] = ColorSequence.new(kps)
5317
+ elseif #kps == 1 then
5318
+ targetInstance[prop] = ColorSequence.new(kps[1].Value)
5319
+ end
5320
+ elseif typeof(currentVal) == "NumberSequence" or (type(val) == "table" and val.t == "NumberSequence") then
5321
+ local kps = {}
5322
+ local rawList = (type(val) == "table" and val.t == "NumberSequence") and val.v or val
5323
+ if type(rawList) == "table" then
5324
+ for _, kp in ipairs(rawList) do
5325
+ if type(kp) == "table" then
5326
+ local t = kp[1] or 0
5327
+ local v = kp[2] or 0
5328
+ local env = kp[3] or 0
5329
+ table.insert(kps, NumberSequenceKeypoint.new(math.clamp(t, 0, 1), v, env))
5330
+ end
5331
+ end
5332
+ end
5333
+ if #kps >= 2 then
5334
+ table.sort(kps, function(a, b) return a.Time < b.Time end)
5335
+ -- Pastikan keypoint pertama t=0 dan terakhir t=1
5336
+ if kps[1].Time ~= 0 then
5337
+ kps[1] = NumberSequenceKeypoint.new(0, kps[1].Value, kps[1].Envelope)
5338
+ end
5339
+ if kps[#kps].Time ~= 1 then
5340
+ kps[#kps] = NumberSequenceKeypoint.new(1, kps[#kps].Value, kps[#kps].Envelope)
5341
+ end
5342
+ -- Pastikan waktu meningkat secara monoton murni
5343
+ for i = 2, #kps do
5344
+ if kps[i].Time <= kps[i - 1].Time then
5345
+ local newT = math.min(1.0, kps[i - 1].Time + 0.001)
5346
+ kps[i] = NumberSequenceKeypoint.new(newT, kps[i].Value, kps[i].Envelope)
5347
+ end
5348
+ end
5349
+ targetInstance[prop] = NumberSequence.new(kps)
5350
+ elseif #kps == 1 then
5351
+ targetInstance[prop] = NumberSequence.new(kps[1].Value)
5352
+ end
2536
5353
  elseif typeof(currentVal) == "EnumItem" and type(val) == "string" then
2537
5354
  local enumType = tostring(currentVal.EnumType)
2538
5355
  local successEnum, enumValue = pcall(function() return Enum[enumType][val] end)
@@ -2581,6 +5398,82 @@ return SecurityScanner
2581
5398
  end
2582
5399
  return { status = "success", result = "Berhasil mengubah properti: " .. table.concat(applied, ", ") }
2583
5400
  end
5401
+ ]]></ProtectedString>
5402
+ </Properties>
5403
+ </Item>
5404
+ <Item class="ModuleScript" referent="RBX_solve_limb_ik">
5405
+ <Properties>
5406
+ <BinaryString name="AttributesSerialize"></BinaryString>
5407
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
5408
+ <bool name="DefinesCapabilities">false</bool>
5409
+ <bool name="Disabled">false</bool>
5410
+ <Content name="LinkedSource"><null></null></Content>
5411
+ <string name="Name">solve_limb_ik</string>
5412
+ <int64 name="SourceAssetId">-1</int64>
5413
+ <BinaryString name="Tags"></BinaryString>
5414
+ <ProtectedString name="Source"><![CDATA[--!strict
5415
+ -- ══════════════════════════════════════════════════════════════════════
5416
+ -- Skill Module: solve_limb_ik
5417
+ -- Deskripsi: Semantic Tool untuk AI Agent menghitung orientasi sendi
5418
+ -- lengan / kaki menuju koordinat sasaran 3D secara analitis O(1).
5419
+ -- ══════════════════════════════════════════════════════════════════════
5420
+
5421
+ local HttpService = game:GetService("HttpService")
5422
+ local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
5423
+ local Kinematics = require(kinematicsMod)
5424
+
5425
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
5426
+ if not targetInstance then
5427
+ return { status = "error", error = "Target karakter rig untuk solve_limb_ik tidak ditemukan: " .. tostring(targetPath) }
5428
+ end
5429
+
5430
+ local rigInfo = Kinematics.inspectRig(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
5431
+ if not rigInfo then
5432
+ return { status = "error", error = "Target bukan model karakter rig (R6/R15) yang valid: " .. targetInstance:GetFullName() }
5433
+ end
5434
+
5435
+ local payload: { [string]: any } = {}
5436
+ pcall(function()
5437
+ if type(data) == "table" then payload = data
5438
+ else payload = HttpService:JSONDecode(tostring(data)) end
5439
+ end)
5440
+
5441
+ local limb = tostring(payload.limb or "RightArm")
5442
+ local goalArr = payload.goal
5443
+ if not goalArr or type(goalArr) ~= "table" or #goalArr < 3 then
5444
+ return { status = "error", error = "Parameter 'goal' [X, Y, Z] wajib diisi." }
5445
+ end
5446
+
5447
+ local goalPos = Vector3.new(goalArr[1], goalArr[2], goalArr[3])
5448
+ local polePos: Vector3? = nil
5449
+ if payload.pole and type(payload.pole) == "table" and #payload.pole >= 3 then
5450
+ polePos = Vector3.new(payload.pole[1], payload.pole[2], payload.pole[3])
5451
+ end
5452
+
5453
+ local apply = payload.apply_to_rig ~= false
5454
+ local solved = Kinematics.solveLimb(rigInfo, limb, goalPos, polePos, apply)
5455
+
5456
+ if not solved then
5457
+ return { status = "error", error = "Gagal menyelesaikan IK untuk anggota tubuh: " .. limb }
5458
+ end
5459
+
5460
+ local resultC0: { [string]: any } = {}
5461
+ for mName, cf in pairs(solved) do
5462
+ resultC0[mName] = { t = "CFrame", v = { cf:GetComponents() } }
5463
+ end
5464
+
5465
+ return {
5466
+ status = "success",
5467
+ result = {
5468
+ limb = limb,
5469
+ rigType = rigInfo.isR15 and "R15" or "R6",
5470
+ applied = apply,
5471
+ targetGoal = { goalPos.X, goalPos.Y, goalPos.Z },
5472
+ solvedMotors = resultC0
5473
+ }
5474
+ }
5475
+ end
5476
+
2584
5477
  ]]></ProtectedString>
2585
5478
  </Properties>
2586
5479
  </Item>
@@ -2691,7 +5584,7 @@ function UIManager.init(pluginInstance, studioId)
2691
5584
 
2692
5585
  -- Version
2693
5586
  local versionLabel = Instance.new("TextLabel")
2694
- versionLabel.Text = "v2.3.0"
5587
+ versionLabel.Text = "v2.4.1"
2695
5588
  versionLabel.Font = Enum.Font.Gotham
2696
5589
  versionLabel.TextSize = 11
2697
5590
  versionLabel.TextColor3 = Color3.fromRGB(120, 120, 130)