@adiholick/nvstudio-mcp 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -3
- package/agent-plugin/rules/AGENTS.md +56 -0
- package/agent-plugin/skills/nvstudio-animation-vfx/SKILL.md +217 -0
- package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +14 -0
- package/agent-plugin/skills/nvstudio-rig-engine/SKILL.md +170 -0
- package/dist/index.js +625 -0
- package/dist/roblox-bridge.js +108 -0
- package/dist/vfx-database.js +507 -0
- package/package.json +2 -2
- package/public/index.html +913 -14
- package/studio-plugin/Main.server.lua +1 -1
- package/studio-plugin/animate_character.lua +36 -0
- package/studio-plugin/apply_vfx.lua +59 -0
- package/studio-plugin/edit_rig.lua +35 -0
- package/studio-plugin/nv_anim_dispatcher.lua +239 -0
- package/studio-plugin/nv_auto_rigger.lua +392 -0
- package/studio-plugin/nv_curves.lua +260 -0
- package/studio-plugin/nv_joint_math.lua +138 -0
- package/studio-plugin/nv_kinematics.lua +248 -0
- package/studio-plugin/nv_motion_generator.lua +279 -0
- package/studio-plugin/nv_rig_dispatcher.lua +319 -0
- package/studio-plugin/nv_vfx_serializer.lua +363 -0
- package/studio-plugin/nvstudio_mcp.rbxmx +3008 -115
- package/studio-plugin/scan_vfx.lua +85 -0
- package/studio-plugin/scrub_timeline.lua +122 -0
- package/studio-plugin/set_properties.lua +73 -0
- package/studio-plugin/solve_limb_ik.lua +63 -0
- package/studio-plugin/ui_manager.lua +1 -1
|
@@ -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.
|
|
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="
|
|
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">
|
|
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[
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1864
|
+
if isR15 then
|
|
1865
|
+
-- LowerTorso pose
|
|
1866
|
+
local lowerTorsoPose = createPose("LowerTorso", kfData.poses["Root"], nil)
|
|
1644
1867
|
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
local affectedLines = 0
|
|
1868
|
+
-- UpperTorso pose
|
|
1869
|
+
local upperTorsoPose = createPose("UpperTorso", kfData.poses["Waist"], nil)
|
|
1648
1870
|
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
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
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
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
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
-
|
|
1714
|
-
|
|
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
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
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
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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="
|
|
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">
|
|
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
|
-
--
|
|
1762
|
-
-- Deskripsi:
|
|
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
|
|
1766
|
-
local
|
|
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.
|
|
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)
|