@adiholick/nvstudio-mcp 2.2.3 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -47
- package/agent-plugin/mcp_config.json +8 -0
- package/agent-plugin/plugin.json +6 -0
- package/agent-plugin/rules/AGENTS.md +118 -0
- package/agent-plugin/skills/nvstudio-animation-vfx/SKILL.md +120 -0
- package/agent-plugin/skills/nvstudio-assets-terrain-guide/SKILL.md +27 -0
- package/agent-plugin/skills/nvstudio-gui-standards/SKILL.md +419 -0
- package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +83 -0
- package/agent-plugin/skills/nvstudio-rig-engine/SKILL.md +170 -0
- package/agent-plugin/skills/nvstudio-roblox-sync-guide/SKILL.md +16 -0
- package/dist/build-plugin.js +55 -0
- package/dist/index.js +841 -3
- package/dist/luau-validator.js +99 -34
- package/dist/roblox-bridge.js +152 -4
- package/dist/roblox-docs.js +264 -0
- package/dist/vfx-database.js +507 -0
- package/package.json +6 -4
- package/public/index.html +1590 -180
- package/studio-plugin/Main.server.lua +1 -1
- package/studio-plugin/animate_character.lua +36 -0
- package/studio-plugin/apply_vfx.lua +59 -0
- package/studio-plugin/batch_create_instances.lua +128 -0
- package/studio-plugin/batch_set_properties.lua +69 -0
- package/studio-plugin/edit_rig.lua +35 -0
- package/studio-plugin/insert_asset.lua +85 -12
- package/studio-plugin/nv_anim_dispatcher.lua +239 -0
- package/studio-plugin/nv_auto_rigger.lua +392 -0
- package/studio-plugin/nv_curves.lua +260 -0
- package/studio-plugin/nv_joint_math.lua +138 -0
- package/studio-plugin/nv_kinematics.lua +248 -0
- package/studio-plugin/nv_motion_generator.lua +279 -0
- package/studio-plugin/nv_rig_dispatcher.lua +319 -0
- package/studio-plugin/nv_vfx_serializer.lua +363 -0
- package/studio-plugin/nvstudio_mcp.rbxmx +3824 -60
- package/studio-plugin/preview_asset.lua +99 -0
- package/studio-plugin/scan_security.lua +81 -0
- package/studio-plugin/scan_vfx.lua +85 -0
- package/studio-plugin/scrub_timeline.lua +122 -0
- package/studio-plugin/security_scanner.lua +346 -0
- package/studio-plugin/set_properties.lua +83 -0
- package/studio-plugin/solve_limb_ik.lua +63 -0
- package/studio-plugin/ui_manager.lua +1 -1
|
@@ -10,6 +10,350 @@
|
|
|
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>
|
|
134
|
+
<Item class="ModuleScript" referent="RBX_batch_create_instances">
|
|
135
|
+
<Properties>
|
|
136
|
+
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
137
|
+
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
138
|
+
<bool name="DefinesCapabilities">false</bool>
|
|
139
|
+
<bool name="Disabled">false</bool>
|
|
140
|
+
<Content name="LinkedSource"><null></null></Content>
|
|
141
|
+
<string name="Name">batch_create_instances</string>
|
|
142
|
+
<int64 name="SourceAssetId">-1</int64>
|
|
143
|
+
<BinaryString name="Tags"></BinaryString>
|
|
144
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
145
|
+
-- ============================================================================
|
|
146
|
+
-- Skill Module: batch_create_instances
|
|
147
|
+
-- Deskripsi: Pembangun Hierarki Instance Massal & Deklaratif (Tree / Batch List)
|
|
148
|
+
-- ============================================================================
|
|
149
|
+
|
|
150
|
+
local HttpService = game:GetService("HttpService")
|
|
151
|
+
|
|
152
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
153
|
+
local success, config = pcall(function()
|
|
154
|
+
if type(data) == "table" then return data end
|
|
155
|
+
return HttpService:JSONDecode(tostring(data))
|
|
156
|
+
end)
|
|
157
|
+
|
|
158
|
+
if not success or type(config) ~= "table" then
|
|
159
|
+
return { status = "error", error = "Format data JSON tidak valid untuk batch_create_instances." }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
local setPropsFunc = require(script.Parent:WaitForChild("set_properties"))
|
|
163
|
+
local defaultParent = targetInstance or game.Workspace
|
|
164
|
+
|
|
165
|
+
if config.target and ctx and ctx.getInstanceFromPath then
|
|
166
|
+
local resolved = ctx.getInstanceFromPath(config.target)
|
|
167
|
+
if resolved then defaultParent = resolved end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
local createdList = {}
|
|
171
|
+
local errors = {}
|
|
172
|
+
local createdCount = 0
|
|
173
|
+
|
|
174
|
+
local function applyProps(inst: Instance, props: any)
|
|
175
|
+
if not props or type(props) ~= "table" or not next(props) then return end
|
|
176
|
+
local propJson = HttpService:JSONEncode(props)
|
|
177
|
+
local res = setPropsFunc(inst, propJson, ctx, inst:GetFullName())
|
|
178
|
+
if res and res.status == "error" then
|
|
179
|
+
table.insert(errors, string.format("[%s] Error properti: %s", inst.Name, tostring(res.result or res.error)))
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
-- 1. MODE HIERARCHICAL TREE (Pohon Bersarang)
|
|
184
|
+
local function buildTreeNode(parentInst: Instance, node: any): Instance?
|
|
185
|
+
local className = node.className or node.ClassName
|
|
186
|
+
if not className or type(className) ~= "string" then
|
|
187
|
+
table.insert(errors, "Node pohon tidak memiliki ClassName yang valid.")
|
|
188
|
+
return nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
local ok, inst = pcall(function()
|
|
192
|
+
local newObj = Instance.new(className)
|
|
193
|
+
if node.name or node.Name then
|
|
194
|
+
newObj.Name = tostring(node.name or node.Name)
|
|
195
|
+
end
|
|
196
|
+
newObj.Parent = parentInst
|
|
197
|
+
return newObj
|
|
198
|
+
end)
|
|
199
|
+
|
|
200
|
+
if not ok or not inst then
|
|
201
|
+
table.insert(errors, string.format("Gagal membuat instance '%s': %s", className, tostring(inst)))
|
|
202
|
+
return nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
createdCount = createdCount + 1
|
|
206
|
+
table.insert(createdList, inst:GetFullName())
|
|
207
|
+
|
|
208
|
+
-- Terapkan properti
|
|
209
|
+
applyProps(inst, node.properties or node.Properties)
|
|
210
|
+
|
|
211
|
+
-- Rekursif untuk seluruh child
|
|
212
|
+
local children = node.children or node.Children
|
|
213
|
+
if children and type(children) == "table" then
|
|
214
|
+
for _, childNode in ipairs(children) do
|
|
215
|
+
buildTreeNode(inst, childNode)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
return inst
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
-- Eksekusi Mode Tree jika disediakan
|
|
223
|
+
if config.tree and type(config.tree) == "table" then
|
|
224
|
+
buildTreeNode(defaultParent, config.tree)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
-- 2. MODE FLAT ARRAY (Daftar Instance Terpisah)
|
|
228
|
+
local instancesArray = config.instances or config.items
|
|
229
|
+
if instancesArray and type(instancesArray) == "table" then
|
|
230
|
+
for idx, item in ipairs(instancesArray) do
|
|
231
|
+
local parentInst = defaultParent
|
|
232
|
+
if item.parent and ctx and ctx.getInstanceFromPath then
|
|
233
|
+
local resParent = ctx.getInstanceFromPath(item.parent)
|
|
234
|
+
if resParent then parentInst = resParent end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
local className = item.className or item.ClassName
|
|
238
|
+
if className then
|
|
239
|
+
local ok, inst = pcall(function()
|
|
240
|
+
local newObj = Instance.new(className)
|
|
241
|
+
if item.name or item.Name then
|
|
242
|
+
newObj.Name = tostring(item.name or item.Name)
|
|
243
|
+
end
|
|
244
|
+
newObj.Parent = parentInst
|
|
245
|
+
return newObj
|
|
246
|
+
end)
|
|
247
|
+
|
|
248
|
+
if ok and inst then
|
|
249
|
+
createdCount = createdCount + 1
|
|
250
|
+
table.insert(createdList, inst:GetFullName())
|
|
251
|
+
applyProps(inst, item.properties or item.Properties)
|
|
252
|
+
else
|
|
253
|
+
table.insert(errors, string.format("Item #%d (%s) gagal dibuat: %s", idx, tostring(className), tostring(inst)))
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
local status = (createdCount > 0 or #errors == 0) and "success" or "error"
|
|
260
|
+
return {
|
|
261
|
+
status = status,
|
|
262
|
+
result = {
|
|
263
|
+
totalCreated = createdCount,
|
|
264
|
+
created = createdList,
|
|
265
|
+
hasErrors = (#errors > 0),
|
|
266
|
+
errors = errors,
|
|
267
|
+
message = string.format("Berhasil membuat %d instance secara batch.", createdCount)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
]]></ProtectedString>
|
|
273
|
+
</Properties>
|
|
274
|
+
</Item>
|
|
275
|
+
<Item class="ModuleScript" referent="RBX_batch_set_properties">
|
|
276
|
+
<Properties>
|
|
277
|
+
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
278
|
+
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
279
|
+
<bool name="DefinesCapabilities">false</bool>
|
|
280
|
+
<bool name="Disabled">false</bool>
|
|
281
|
+
<Content name="LinkedSource"><null></null></Content>
|
|
282
|
+
<string name="Name">batch_set_properties</string>
|
|
283
|
+
<int64 name="SourceAssetId">-1</int64>
|
|
284
|
+
<BinaryString name="Tags"></BinaryString>
|
|
285
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
286
|
+
-- ============================================================================
|
|
287
|
+
-- Skill Module: batch_set_properties
|
|
288
|
+
-- Deskripsi: Modifikasi Properti Massal untuk Beberapa Instance Sekaligus
|
|
289
|
+
-- ============================================================================
|
|
290
|
+
|
|
291
|
+
local HttpService = game:GetService("HttpService")
|
|
292
|
+
|
|
293
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
294
|
+
local success, config = pcall(function()
|
|
295
|
+
if type(data) == "table" then return data end
|
|
296
|
+
return HttpService:JSONDecode(tostring(data))
|
|
297
|
+
end)
|
|
298
|
+
|
|
299
|
+
if not success or type(config) ~= "table" then
|
|
300
|
+
return { status = "error", error = "Format data JSON tidak valid untuk batch_set_properties." }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
local setPropsFunc = require(script.Parent:WaitForChild("set_properties"))
|
|
304
|
+
local updates = config.updates or config
|
|
305
|
+
if type(updates) ~= "table" then
|
|
306
|
+
return { status = "error", error = "Parameter 'updates' harus berupa array pembaruan properti." }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
local updatedCount = 0
|
|
310
|
+
local successfulTargets = {}
|
|
311
|
+
local errors = {}
|
|
312
|
+
|
|
313
|
+
for idx, updateItem in ipairs(updates) do
|
|
314
|
+
local inst = targetInstance
|
|
315
|
+
local instPath = updateItem.target or updateItem.path
|
|
316
|
+
if instPath and ctx and ctx.getInstanceFromPath then
|
|
317
|
+
local resolved = ctx.getInstanceFromPath(instPath)
|
|
318
|
+
if resolved then
|
|
319
|
+
inst = resolved
|
|
320
|
+
else
|
|
321
|
+
table.insert(errors, string.format("Item #%d: Target path '%s' tidak ditemukan.", idx, tostring(instPath)))
|
|
322
|
+
continue
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
local props = updateItem.properties or updateItem.props
|
|
327
|
+
if inst and props and type(props) == "table" then
|
|
328
|
+
local propJson = HttpService:JSONEncode(props)
|
|
329
|
+
local res = setPropsFunc(inst, propJson, ctx, inst:GetFullName())
|
|
330
|
+
if res and res.status == "error" then
|
|
331
|
+
table.insert(errors, string.format("[%s]: %s", inst.Name, tostring(res.result or res.error)))
|
|
332
|
+
else
|
|
333
|
+
updatedCount = updatedCount + 1
|
|
334
|
+
table.insert(successfulTargets, inst:GetFullName())
|
|
335
|
+
end
|
|
336
|
+
else
|
|
337
|
+
table.insert(errors, string.format("Item #%d: Instance atau properties tidak valid.", idx))
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
local status = (updatedCount > 0 or #errors == 0) and "success" or "error"
|
|
342
|
+
return {
|
|
343
|
+
status = status,
|
|
344
|
+
result = {
|
|
345
|
+
totalUpdated = updatedCount,
|
|
346
|
+
updatedInstances = successfulTargets,
|
|
347
|
+
hasErrors = (#errors > 0),
|
|
348
|
+
errors = errors,
|
|
349
|
+
message = string.format("Berhasil memperbarui properti untuk %d instance.", updatedCount)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
]]></ProtectedString>
|
|
355
|
+
</Properties>
|
|
356
|
+
</Item>
|
|
13
357
|
<Item class="ModuleScript" referent="RBX_create_instance">
|
|
14
358
|
<Properties>
|
|
15
359
|
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
@@ -107,6 +451,54 @@ end
|
|
|
107
451
|
return { status = "error", error = "Gagal menghapus instance: " .. tostring(err) }
|
|
108
452
|
end
|
|
109
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
|
+
|
|
110
502
|
]]></ProtectedString>
|
|
111
503
|
</Properties>
|
|
112
504
|
</Item>
|
|
@@ -473,28 +865,101 @@ end
|
|
|
473
865
|
<string name="Name">insert_asset</string>
|
|
474
866
|
<int64 name="SourceAssetId">-1</int64>
|
|
475
867
|
<BinaryString name="Tags"></BinaryString>
|
|
476
|
-
<ProtectedString name="Source"><![CDATA[
|
|
477
|
-
|
|
478
|
-
|
|
868
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
869
|
+
-- ============================================================================
|
|
870
|
+
-- Skill Module: insert_asset (Upgraded with Asset Security Scanner & Script Stripper)
|
|
871
|
+
-- ============================================================================
|
|
872
|
+
|
|
873
|
+
local HttpService = game:GetService("HttpService")
|
|
874
|
+
local InsertService = game:GetService("InsertService")
|
|
875
|
+
|
|
876
|
+
local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
|
|
877
|
+
|
|
878
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
879
|
+
local assetIdStr = ""
|
|
880
|
+
local stripScripts = true -- Default TRUE demi keamanan mutlak
|
|
881
|
+
local scanSecurity = true
|
|
882
|
+
|
|
883
|
+
if typeof(data) == "string" then
|
|
884
|
+
local success, parsed = pcall(function()
|
|
885
|
+
return HttpService:JSONDecode(data)
|
|
886
|
+
end)
|
|
887
|
+
if success and typeof(parsed) == "table" then
|
|
888
|
+
assetIdStr = tostring(parsed.asset_id or parsed.assetId or "")
|
|
889
|
+
if parsed.strip_scripts ~= nil then stripScripts = parsed.strip_scripts end
|
|
890
|
+
if parsed.scan_security ~= nil then scanSecurity = parsed.scan_security end
|
|
891
|
+
else
|
|
892
|
+
assetIdStr = data
|
|
893
|
+
end
|
|
894
|
+
elseif typeof(data) == "table" then
|
|
895
|
+
assetIdStr = tostring(data.asset_id or data.assetId or "")
|
|
896
|
+
if data.strip_scripts ~= nil then stripScripts = data.strip_scripts end
|
|
897
|
+
if data.scan_security ~= nil then scanSecurity = data.scan_security end
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
local rawId = string.gsub(assetIdStr, "rbxassetid://", "")
|
|
901
|
+
rawId = string.gsub(rawId, "%s+", "")
|
|
479
902
|
local assetId = tonumber(rawId)
|
|
480
|
-
|
|
903
|
+
|
|
904
|
+
if not assetId or assetId <= 0 then
|
|
481
905
|
return { status = "error", error = "Asset ID harus berupa angka yang valid." }
|
|
482
906
|
end
|
|
483
|
-
|
|
907
|
+
|
|
484
908
|
local success, model = pcall(function()
|
|
485
909
|
return InsertService:LoadAsset(assetId)
|
|
486
910
|
end)
|
|
487
|
-
|
|
488
|
-
if success
|
|
489
|
-
|
|
490
|
-
|
|
911
|
+
|
|
912
|
+
if not success or not model then
|
|
913
|
+
return {
|
|
914
|
+
status = "error",
|
|
915
|
+
error = "Gagal memuat aset. Pastikan Asset ID benar dan model berstatus publik di Creator Store."
|
|
916
|
+
}
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
local securityNotes = {}
|
|
920
|
+
local removedScripts = {}
|
|
921
|
+
|
|
922
|
+
-- 1. Scan keamanan jika diaktifkan
|
|
923
|
+
if scanSecurity then
|
|
924
|
+
local scanResult = SecurityScanner.scanInstance(model, true)
|
|
925
|
+
if scanResult.riskLevel == "DANGEROUS" then
|
|
926
|
+
table.insert(securityNotes, string.format("⚠️ PERINGATAN: Aset ini memiliki %d ancaman BERBAHAYA (Backdoor/Obfuscation/Webhook)!", scanResult.highThreats))
|
|
927
|
+
end
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
-- 2. Sanitasi: Hapus skrip jika strip_scripts = true
|
|
931
|
+
if stripScripts then
|
|
932
|
+
removedScripts = SecurityScanner.stripScripts(model)
|
|
933
|
+
if #removedScripts > 0 then
|
|
934
|
+
table.insert(securityNotes, string.format("🛡️ Keamanan: %d skrip bawaan telah dihapus secara otomatis demi keamanan game Anda.", #removedScripts))
|
|
491
935
|
else
|
|
492
|
-
|
|
936
|
+
table.insert(securityNotes, "🛡️ Keamanan: Aset bersih, tidak ditemukan skrip berbahaya.")
|
|
493
937
|
end
|
|
494
|
-
return { status = "success", result = "Aset berhasil dimasukkan ke " .. model.Parent.Name .. "." }
|
|
495
938
|
else
|
|
496
|
-
|
|
939
|
+
table.insert(securityNotes, "ℹ️ Catatan: strip_scripts = false, skrip bawaan model dipertahankan.")
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
-- 3. Masukkan model ke target
|
|
943
|
+
local parentTarget = targetInstance
|
|
944
|
+
if not parentTarget or parentTarget == game or parentTarget.ClassName == "DataModel" then
|
|
945
|
+
parentTarget = game.Workspace
|
|
497
946
|
end
|
|
947
|
+
|
|
948
|
+
model.Parent = parentTarget
|
|
949
|
+
|
|
950
|
+
return {
|
|
951
|
+
status = "success",
|
|
952
|
+
result = {
|
|
953
|
+
message = string.format("Aset '%s' (ID: %d) berhasil dimasukkan ke %s.", model.Name, assetId, parentTarget.Name),
|
|
954
|
+
assetName = model.Name,
|
|
955
|
+
assetId = assetId,
|
|
956
|
+
parent = parentTarget:GetFullName(),
|
|
957
|
+
stripScriptsApplied = stripScripts,
|
|
958
|
+
removedScriptsCount = #removedScripts,
|
|
959
|
+
removedScripts = removedScripts,
|
|
960
|
+
securityNotes = table.concat(securityNotes, "\n"),
|
|
961
|
+
}
|
|
962
|
+
}
|
|
498
963
|
end
|
|
499
964
|
]]></ProtectedString>
|
|
500
965
|
</Properties>
|
|
@@ -511,7 +976,7 @@ end
|
|
|
511
976
|
<BinaryString name="Tags"></BinaryString>
|
|
512
977
|
<token name="RunContext">0</token>
|
|
513
978
|
<ProtectedString name="Source"><![CDATA[-- ══════════════════════════════════════════════════════════════════════
|
|
514
|
-
-- NVStudio MCP Plugin v2.
|
|
979
|
+
-- NVStudio MCP Plugin v2.4.0
|
|
515
980
|
-- Arsitektur: Dual-Mode WebSocket Duplex (Port 3055/ws) + Fallback Long-Polling
|
|
516
981
|
-- Plugin auto-connect saat dimuat via WebSocket native, dengan fallback otomatis
|
|
517
982
|
-- ke HTTP stream jika WebSocket tidak tersedia di environment Roblox Studio ini.
|
|
@@ -1320,66 +1785,2408 @@ end)
|
|
|
1320
1785
|
]]></ProtectedString>
|
|
1321
1786
|
</Properties>
|
|
1322
1787
|
</Item>
|
|
1323
|
-
<Item class="ModuleScript" referent="
|
|
1788
|
+
<Item class="ModuleScript" referent="RBX_nv_anim_dispatcher">
|
|
1324
1789
|
<Properties>
|
|
1325
1790
|
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
1326
1791
|
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
1327
1792
|
<bool name="DefinesCapabilities">false</bool>
|
|
1328
1793
|
<bool name="Disabled">false</bool>
|
|
1329
1794
|
<Content name="LinkedSource"><null></null></Content>
|
|
1330
|
-
<string name="Name">
|
|
1795
|
+
<string name="Name">nv_anim_dispatcher</string>
|
|
1331
1796
|
<int64 name="SourceAssetId">-1</int64>
|
|
1332
1797
|
<BinaryString name="Tags"></BinaryString>
|
|
1333
|
-
<ProtectedString name="Source"><![CDATA[
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
1339
1805
|
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
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
|
|
1344
1822
|
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
|
1348
1863
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1864
|
+
if isR15 then
|
|
1865
|
+
-- LowerTorso pose
|
|
1866
|
+
local lowerTorsoPose = createPose("LowerTorso", kfData.poses["Root"], nil)
|
|
1352
1867
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
local targetContent = payload.target_content
|
|
1356
|
-
local replacementContent = payload.replacement_content
|
|
1357
|
-
local allowMultiple = payload.allow_multiple or false
|
|
1868
|
+
-- UpperTorso pose
|
|
1869
|
+
local upperTorsoPose = createPose("UpperTorso", kfData.poses["Waist"], nil)
|
|
1358
1870
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1871
|
+
-- Head
|
|
1872
|
+
if kfData.poses["Neck"] then
|
|
1873
|
+
createPose("Head", kfData.poses["Neck"], upperTorsoPose)
|
|
1874
|
+
end
|
|
1362
1875
|
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
|
1888
|
+
|
|
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
|
|
1901
|
+
|
|
1902
|
+
upperTorsoPose.Parent = lowerTorsoPose
|
|
1903
|
+
lowerTorsoPose.Parent = rootPose
|
|
1904
|
+
else
|
|
1905
|
+
-- R6 Pose Structure
|
|
1906
|
+
local torsoPose = createPose("Torso", kfData.poses["RootJoint"], nil)
|
|
1907
|
+
|
|
1908
|
+
if kfData.poses["Neck"] then
|
|
1909
|
+
createPose("Head", kfData.poses["Neck"], torsoPose)
|
|
1910
|
+
end
|
|
1911
|
+
|
|
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
|
|
1921
|
+
|
|
1922
|
+
torsoPose.Parent = rootPose
|
|
1923
|
+
end
|
|
1924
|
+
|
|
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
|
|
1937
|
+
|
|
1938
|
+
rootPose.Parent = kf
|
|
1939
|
+
kf.Parent = seq
|
|
1940
|
+
end
|
|
1941
|
+
|
|
1942
|
+
return seq
|
|
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
|
+
|
|
2037
|
+
]]></ProtectedString>
|
|
2038
|
+
</Properties>
|
|
2039
|
+
</Item>
|
|
2040
|
+
<Item class="ModuleScript" referent="RBX_nv_auto_rigger">
|
|
2041
|
+
<Properties>
|
|
2042
|
+
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
2043
|
+
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
2044
|
+
<bool name="DefinesCapabilities">false</bool>
|
|
2045
|
+
<bool name="Disabled">false</bool>
|
|
2046
|
+
<Content name="LinkedSource"><null></null></Content>
|
|
2047
|
+
<string name="Name">nv_auto_rigger</string>
|
|
2048
|
+
<int64 name="SourceAssetId">-1</int64>
|
|
2049
|
+
<BinaryString name="Tags"></BinaryString>
|
|
2050
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
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
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
2057
|
+
|
|
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
|
|
1383
4190
|
|
|
1384
4191
|
local function countOccurrences(str, pattern)
|
|
1385
4192
|
local count = 0
|
|
@@ -1447,6 +4254,118 @@ return function(targetInstance, data, ctx, targetPath)
|
|
|
1447
4254
|
affected_lines = affectedLines
|
|
1448
4255
|
}
|
|
1449
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")
|
|
4278
|
+
|
|
4279
|
+
local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
|
|
4280
|
+
|
|
4281
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
4282
|
+
local assetIdStr = ""
|
|
4283
|
+
local deepScan = true
|
|
4284
|
+
|
|
4285
|
+
if typeof(data) == "string" then
|
|
4286
|
+
local success, parsed = pcall(function()
|
|
4287
|
+
return HttpService:JSONDecode(data)
|
|
4288
|
+
end)
|
|
4289
|
+
if success and typeof(parsed) == "table" then
|
|
4290
|
+
assetIdStr = tostring(parsed.asset_id or parsed.assetId or "")
|
|
4291
|
+
if parsed.deep_scan ~= nil then deepScan = parsed.deep_scan end
|
|
4292
|
+
else
|
|
4293
|
+
assetIdStr = data
|
|
4294
|
+
end
|
|
4295
|
+
elseif typeof(data) == "table" then
|
|
4296
|
+
assetIdStr = tostring(data.asset_id or data.assetId or "")
|
|
4297
|
+
if data.deep_scan ~= nil then deepScan = data.deep_scan end
|
|
4298
|
+
end
|
|
4299
|
+
|
|
4300
|
+
local cleanIdStr = string.gsub(assetIdStr, "rbxassetid://", "")
|
|
4301
|
+
cleanIdStr = string.gsub(cleanIdStr, "%s+", "")
|
|
4302
|
+
local assetId = tonumber(cleanIdStr)
|
|
4303
|
+
|
|
4304
|
+
if not assetId or assetId <= 0 then
|
|
4305
|
+
return { status = "error", error = "Asset ID tidak valid. Berikan Asset ID angka (contoh: 123456789)." }
|
|
4306
|
+
end
|
|
4307
|
+
|
|
4308
|
+
-- Muat Aset secara terisolasi (unparented / memory only)
|
|
4309
|
+
local ok, model = pcall(function()
|
|
4310
|
+
return InsertService:LoadAsset(assetId)
|
|
4311
|
+
end)
|
|
4312
|
+
|
|
4313
|
+
if not ok or not model then
|
|
4314
|
+
return {
|
|
4315
|
+
status = "error",
|
|
4316
|
+
error = string.format("Gagal memuat aset ID %d. Pastikan Asset ID publik dan terdaftar di Creator Store.", assetId)
|
|
4317
|
+
}
|
|
4318
|
+
end
|
|
4319
|
+
|
|
4320
|
+
local scanResult
|
|
4321
|
+
local scanOk, err = pcall(function()
|
|
4322
|
+
scanResult = SecurityScanner.scanInstance(model, deepScan)
|
|
4323
|
+
end)
|
|
4324
|
+
|
|
4325
|
+
local rootName = model.Name
|
|
4326
|
+
local childrenOverview = {}
|
|
4327
|
+
for _, child in ipairs(model:GetChildren()) do
|
|
4328
|
+
table.insert(childrenOverview, {
|
|
4329
|
+
name = child.Name,
|
|
4330
|
+
className = child.ClassName,
|
|
4331
|
+
})
|
|
4332
|
+
end
|
|
4333
|
+
|
|
4334
|
+
-- Hancurkan model sementara dari memori (Cleanup Mutlak)
|
|
4335
|
+
pcall(function()
|
|
4336
|
+
model:Destroy()
|
|
4337
|
+
end)
|
|
4338
|
+
|
|
4339
|
+
if not scanOk or not scanResult then
|
|
4340
|
+
return {
|
|
4341
|
+
status = "error",
|
|
4342
|
+
error = "Gagal memindai aset: " .. tostring(err)
|
|
4343
|
+
}
|
|
4344
|
+
end
|
|
4345
|
+
|
|
4346
|
+
local recommendation = "Aman untuk dimasukkan langsung menggunakan insert_asset."
|
|
4347
|
+
if scanResult.riskLevel == "DANGEROUS" then
|
|
4348
|
+
recommendation = "PERINGATAN: Aset ini memiliki ancaman BERBAHAYA! Wajib gunakan 'insert_asset' dengan strip_scripts = true, atau hindari aset ini."
|
|
4349
|
+
elseif scanResult.riskLevel == "WARNING" then
|
|
4350
|
+
recommendation = "Perhatian: Aset mengandung skrip atau peringatan moderat. Periksa kembali atau gunakan strip_scripts = true jika skrip tidak diperlukan."
|
|
4351
|
+
end
|
|
4352
|
+
|
|
4353
|
+
return {
|
|
4354
|
+
status = "success",
|
|
4355
|
+
result = {
|
|
4356
|
+
assetId = assetId,
|
|
4357
|
+
assetName = rootName,
|
|
4358
|
+
riskLevel = scanResult.riskLevel,
|
|
4359
|
+
recommendation = recommendation,
|
|
4360
|
+
stats = scanResult.stats,
|
|
4361
|
+
highThreatsCount = scanResult.highThreats,
|
|
4362
|
+
mediumThreatsCount = scanResult.mediumThreats,
|
|
4363
|
+
threats = scanResult.threats,
|
|
4364
|
+
topLevelChildren = childrenOverview,
|
|
4365
|
+
}
|
|
4366
|
+
}
|
|
4367
|
+
end
|
|
4368
|
+
|
|
1450
4369
|
]]></ProtectedString>
|
|
1451
4370
|
</Properties>
|
|
1452
4371
|
</Item>
|
|
@@ -1479,6 +4398,198 @@ end
|
|
|
1479
4398
|
return { status = "error", error = "Target BUKAN Script (ClassName: " .. targetInstance.ClassName .. ")." }
|
|
1480
4399
|
end
|
|
1481
4400
|
end
|
|
4401
|
+
]]></ProtectedString>
|
|
4402
|
+
</Properties>
|
|
4403
|
+
</Item>
|
|
4404
|
+
<Item class="ModuleScript" referent="RBX_scan_security">
|
|
4405
|
+
<Properties>
|
|
4406
|
+
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
4407
|
+
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
4408
|
+
<bool name="DefinesCapabilities">false</bool>
|
|
4409
|
+
<bool name="Disabled">false</bool>
|
|
4410
|
+
<Content name="LinkedSource"><null></null></Content>
|
|
4411
|
+
<string name="Name">scan_security</string>
|
|
4412
|
+
<int64 name="SourceAssetId">-1</int64>
|
|
4413
|
+
<BinaryString name="Tags"></BinaryString>
|
|
4414
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
4415
|
+
-- ============================================================================
|
|
4416
|
+
-- Skill Module: scan_security
|
|
4417
|
+
-- Deskripsi: Audit Keamanan Instance/Model yang Sudah Ada di Tempat Kerja Studio
|
|
4418
|
+
-- ============================================================================
|
|
4419
|
+
|
|
4420
|
+
local HttpService = game:GetService("HttpService")
|
|
4421
|
+
local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
|
|
4422
|
+
|
|
4423
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
4424
|
+
local target = targetInstance or game.Workspace
|
|
4425
|
+
local deepScan = true
|
|
4426
|
+
local cleanThreats = false
|
|
4427
|
+
|
|
4428
|
+
if typeof(data) == "string" and #data > 0 then
|
|
4429
|
+
local success, parsed = pcall(function()
|
|
4430
|
+
return HttpService:JSONDecode(data)
|
|
4431
|
+
end)
|
|
4432
|
+
if success and typeof(parsed) == "table" then
|
|
4433
|
+
if parsed.deep_scan ~= nil then deepScan = parsed.deep_scan end
|
|
4434
|
+
if parsed.clean_threats ~= nil then cleanThreats = parsed.clean_threats end
|
|
4435
|
+
end
|
|
4436
|
+
elseif typeof(data) == "table" then
|
|
4437
|
+
if data.deep_scan ~= nil then deepScan = data.deep_scan end
|
|
4438
|
+
if data.clean_threats ~= nil then cleanThreats = data.clean_threats end
|
|
4439
|
+
end
|
|
4440
|
+
|
|
4441
|
+
local scanResult
|
|
4442
|
+
local ok, err = pcall(function()
|
|
4443
|
+
scanResult = SecurityScanner.scanInstance(target, deepScan)
|
|
4444
|
+
end)
|
|
4445
|
+
|
|
4446
|
+
if not ok or not scanResult then
|
|
4447
|
+
return {
|
|
4448
|
+
status = "error",
|
|
4449
|
+
error = "Gagal memindai target: " .. tostring(err)
|
|
4450
|
+
}
|
|
4451
|
+
end
|
|
4452
|
+
|
|
4453
|
+
local cleanedScripts = {}
|
|
4454
|
+
if cleanThreats and #scanResult.threats > 0 then
|
|
4455
|
+
-- Hapus skrip yang terdeteksi bahaya (HIGH severity)
|
|
4456
|
+
local scriptsToDestroy = {}
|
|
4457
|
+
for _, threat in ipairs(scanResult.threats) do
|
|
4458
|
+
if threat.severity == "HIGH" and threat.scriptPath then
|
|
4459
|
+
scriptsToDestroy[threat.scriptPath] = true
|
|
4460
|
+
end
|
|
4461
|
+
end
|
|
4462
|
+
|
|
4463
|
+
local function purge(instance: Instance)
|
|
4464
|
+
for _, desc in ipairs(instance:GetDescendants()) do
|
|
4465
|
+
if desc:IsA("LuaSourceContainer") and scriptsToDestroy[desc:GetFullName()] then
|
|
4466
|
+
table.insert(cleanedScripts, {
|
|
4467
|
+
name = desc.Name,
|
|
4468
|
+
className = desc.ClassName,
|
|
4469
|
+
path = desc:GetFullName(),
|
|
4470
|
+
})
|
|
4471
|
+
pcall(function() desc:Destroy() end)
|
|
4472
|
+
end
|
|
4473
|
+
end
|
|
4474
|
+
end
|
|
4475
|
+
|
|
4476
|
+
purge(target)
|
|
4477
|
+
end
|
|
4478
|
+
|
|
4479
|
+
return {
|
|
4480
|
+
status = "success",
|
|
4481
|
+
result = {
|
|
4482
|
+
target = target:GetFullName(),
|
|
4483
|
+
riskLevel = scanResult.riskLevel,
|
|
4484
|
+
stats = scanResult.stats,
|
|
4485
|
+
highThreatsCount = scanResult.highThreats,
|
|
4486
|
+
mediumThreatsCount = scanResult.mediumThreats,
|
|
4487
|
+
threats = scanResult.threats,
|
|
4488
|
+
cleanedThreatsCount = #cleanedScripts,
|
|
4489
|
+
cleanedScripts = cleanedScripts,
|
|
4490
|
+
isCleaned = cleanThreats,
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4493
|
+
end
|
|
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
|
+
|
|
1482
4593
|
]]></ProtectedString>
|
|
1483
4594
|
</Properties>
|
|
1484
4595
|
</Item>
|
|
@@ -1572,6 +4683,141 @@ end
|
|
|
1572
4683
|
|
|
1573
4684
|
return { status = "success", matches = matches, count = matchCount, limitReached = (matchCount >= MAX_MATCHES) }
|
|
1574
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
|
+
|
|
1575
4821
|
]]></ProtectedString>
|
|
1576
4822
|
</Properties>
|
|
1577
4823
|
</Item>
|
|
@@ -1624,6 +4870,365 @@ end
|
|
|
1624
4870
|
return { status = "success", result = "Tidak ditemukan instance dengan nama: " .. searchName }
|
|
1625
4871
|
end
|
|
1626
4872
|
end
|
|
4873
|
+
]]></ProtectedString>
|
|
4874
|
+
</Properties>
|
|
4875
|
+
</Item>
|
|
4876
|
+
<Item class="ModuleScript" referent="RBX_security_scanner">
|
|
4877
|
+
<Properties>
|
|
4878
|
+
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
4879
|
+
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
4880
|
+
<bool name="DefinesCapabilities">false</bool>
|
|
4881
|
+
<bool name="Disabled">false</bool>
|
|
4882
|
+
<Content name="LinkedSource"><null></null></Content>
|
|
4883
|
+
<string name="Name">security_scanner</string>
|
|
4884
|
+
<int64 name="SourceAssetId">-1</int64>
|
|
4885
|
+
<BinaryString name="Tags"></BinaryString>
|
|
4886
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
4887
|
+
-- ============================================================================
|
|
4888
|
+
-- Module: security_scanner
|
|
4889
|
+
-- Deskripsi: Engine Heuristik Pemindaian Keamanan & Backdoor Detection
|
|
4890
|
+
-- Standar: Business-Grade, Zero-Execution Isolation, Comprehensive Threat Rules
|
|
4891
|
+
-- ============================================================================
|
|
4892
|
+
|
|
4893
|
+
local SecurityScanner = {}
|
|
4894
|
+
|
|
4895
|
+
-- Daftar nama skrip yang sering digunakan oleh virus/backdoor klasik Roblox
|
|
4896
|
+
local SUSPICIOUS_SCRIPT_NAMES = {
|
|
4897
|
+
"[Vv]accine",
|
|
4898
|
+
"[Aa]nti[Ll]ag",
|
|
4899
|
+
"[Ff]ix[Ll]ag",
|
|
4900
|
+
"[Ii]nfection",
|
|
4901
|
+
"[Ff]ire",
|
|
4902
|
+
"[Cc]lean",
|
|
4903
|
+
"[Vv]irus",
|
|
4904
|
+
"4[Dd]",
|
|
4905
|
+
"4[Dd]_[Pp]lugin",
|
|
4906
|
+
"[Ss]pread",
|
|
4907
|
+
"[Ll]ag[Ff]ixer",
|
|
4908
|
+
"[Dd]up",
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
-- Aturan Pola Heuristik Kode Berbahaya
|
|
4912
|
+
local THREAT_PATTERNS = {
|
|
4913
|
+
{
|
|
4914
|
+
id = "BACKDOOR_REQUIRE",
|
|
4915
|
+
pattern = "require%s*%(%s*%d+%s*%)",
|
|
4916
|
+
severity = "HIGH",
|
|
4917
|
+
name = "Backdoor Numeric Require",
|
|
4918
|
+
description = "Memanggil modul eksternal pihak ketiga via Asset ID numerik (indikasi backdoor/remote loader).",
|
|
4919
|
+
},
|
|
4920
|
+
{
|
|
4921
|
+
id = "LOADSTRING_EXEC",
|
|
4922
|
+
pattern = "loadstring%s*%(",
|
|
4923
|
+
severity = "HIGH",
|
|
4924
|
+
name = "Dynamic Code Execution (loadstring)",
|
|
4925
|
+
description = "Mengeksekusi string kode dinamis yang tidak dapat diaudit.",
|
|
4926
|
+
},
|
|
4927
|
+
{
|
|
4928
|
+
id = "GETFENV_SETFENV",
|
|
4929
|
+
pattern = "[gs]etfenv%s*%(",
|
|
4930
|
+
severity = "HIGH",
|
|
4931
|
+
name = "Environment Manipulation (getfenv/setfenv)",
|
|
4932
|
+
description = "Memanipulasi environment global Luau untuk mengelabui analisis statis.",
|
|
4933
|
+
},
|
|
4934
|
+
{
|
|
4935
|
+
id = "DISCORD_WEBHOOK",
|
|
4936
|
+
pattern = "discord[a-z]*%.com/api/webhooks",
|
|
4937
|
+
severity = "HIGH",
|
|
4938
|
+
name = "Discord Webhook Exfiltration",
|
|
4939
|
+
description = "Mencoba mengirimkan data game atau informasi pemain ke webhook eksternal Discord.",
|
|
4940
|
+
},
|
|
4941
|
+
{
|
|
4942
|
+
id = "IP_LOGGER",
|
|
4943
|
+
pattern = "webhook%.site|grabify%.link|iplogger|blasze",
|
|
4944
|
+
severity = "HIGH",
|
|
4945
|
+
name = "External IP/Data Logger",
|
|
4946
|
+
description = "Menghubungi domain pencatat IP atau data capture pihak ketiga.",
|
|
4947
|
+
},
|
|
4948
|
+
{
|
|
4949
|
+
id = "FORCED_PURCHASE",
|
|
4950
|
+
pattern = "MarketplaceService%s*:%s*Prompt[A-Za-z]*Purchase",
|
|
4951
|
+
severity = "MEDIUM",
|
|
4952
|
+
name = "Forced Robux/Product Purchase",
|
|
4953
|
+
description = "Memicu dialog pembelian paksa Gamepass atau Developer Product.",
|
|
4954
|
+
},
|
|
4955
|
+
{
|
|
4956
|
+
id = "FORCED_TELEPORT",
|
|
4957
|
+
pattern = "TeleportService%s*:%s*Teleport",
|
|
4958
|
+
severity = "MEDIUM",
|
|
4959
|
+
name = "Forced Game Teleportation",
|
|
4960
|
+
description = "Memindahkan pemain secara paksa ke game/place lain.",
|
|
4961
|
+
},
|
|
4962
|
+
{
|
|
4963
|
+
id = "CHAR_TABLE_OBFUSCATION",
|
|
4964
|
+
pattern = "string%.char%s*%(%s*%d+%s*,%s*%d+%s*,%s*%d+",
|
|
4965
|
+
severity = "HIGH",
|
|
4966
|
+
name = "Obfuscated string.char Array",
|
|
4967
|
+
description = "Menyusun kode rahasia tersembunyi menggunakan deretan kode karakter ASCII.",
|
|
4968
|
+
},
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4971
|
+
-- Helper: Pengecekan nama script mencurigakan
|
|
4972
|
+
local function checkSuspiciousName(name: string): boolean
|
|
4973
|
+
for _, pattern in ipairs(SUSPICIOUS_SCRIPT_NAMES) do
|
|
4974
|
+
if string.find(name, pattern) then
|
|
4975
|
+
return true
|
|
4976
|
+
end
|
|
4977
|
+
end
|
|
4978
|
+
return false
|
|
4979
|
+
end
|
|
4980
|
+
|
|
4981
|
+
-- Helper: Deteksi obfuscation byte escape (\108\111\97\100) atau hex (\x6c\x6f)
|
|
4982
|
+
local function checkByteObfuscation(source: string): (boolean, string?)
|
|
4983
|
+
local byteCount = 0
|
|
4984
|
+
for _ in string.gmatch(source, "\\[0-9][0-9][0-9]") do
|
|
4985
|
+
byteCount = byteCount + 1
|
|
4986
|
+
if byteCount >= 4 then
|
|
4987
|
+
return true, "Terdeteksi deretan escape byte ASCII (\\ddd) berulang."
|
|
4988
|
+
end
|
|
4989
|
+
end
|
|
4990
|
+
|
|
4991
|
+
local hexCount = 0
|
|
4992
|
+
for _ in string.gmatch(source, "\\x[0-9a-fA-F][0-9a-fA-F]") do
|
|
4993
|
+
hexCount = hexCount + 1
|
|
4994
|
+
if hexCount >= 4 then
|
|
4995
|
+
return true, "Terdeteksi deretan escape hex (\\xHH) berulang."
|
|
4996
|
+
end
|
|
4997
|
+
end
|
|
4998
|
+
|
|
4999
|
+
return false, nil
|
|
5000
|
+
end
|
|
5001
|
+
|
|
5002
|
+
-- Helper: Deteksi kode yang sengaja disembunyikan dengan spasi/baris kosong berlebih
|
|
5003
|
+
local function checkOffscreenHiding(source: string): (boolean, string?)
|
|
5004
|
+
-- Cek spasi horizontal masif (> 80 spasi berturut-turut)
|
|
5005
|
+
if string.find(source, " " .. string.rep(" ", 80)) then
|
|
5006
|
+
return true, "Kode sengaja didorong ke kanan dengan lebih dari 80 spasi agar tersembunyi dari layar Studio."
|
|
5007
|
+
end
|
|
5008
|
+
|
|
5009
|
+
-- Cek baris kosong masif di awal skrip (> 15 baris kosong)
|
|
5010
|
+
local leadingNewlines = 0
|
|
5011
|
+
for line in string.gmatch(source, "([^\r\n]*)\r?\n") do
|
|
5012
|
+
if string.gsub(line, "%s+", "") == "" then
|
|
5013
|
+
leadingNewlines = leadingNewlines + 1
|
|
5014
|
+
if leadingNewlines >= 15 then
|
|
5015
|
+
return true, "Terdapat lebih dari 15 baris kosong di awal skrip untuk menyembunyikan payload di baris bawah."
|
|
5016
|
+
end
|
|
5017
|
+
else
|
|
5018
|
+
break
|
|
5019
|
+
end
|
|
5020
|
+
end
|
|
5021
|
+
|
|
5022
|
+
return false, nil
|
|
5023
|
+
end
|
|
5024
|
+
|
|
5025
|
+
-- Analisis Source Code dari sebuah Script
|
|
5026
|
+
local function scanScriptSource(scriptInstance: Instance, source: string): { any }
|
|
5027
|
+
local threats = {}
|
|
5028
|
+
local lines = string.split(source, "\n")
|
|
5029
|
+
|
|
5030
|
+
-- 1. Scan pola heuristik baris per baris
|
|
5031
|
+
for lineIdx, line in ipairs(lines) do
|
|
5032
|
+
-- Skip baris komentar murni
|
|
5033
|
+
local trimmed = string.match(line, "^%s*(.-)%s*$") or ""
|
|
5034
|
+
if not string.sub(trimmed, 1, 2) == "--" then
|
|
5035
|
+
for _, rule in ipairs(THREAT_PATTERNS) do
|
|
5036
|
+
local s, e = string.find(line, rule.pattern)
|
|
5037
|
+
if s then
|
|
5038
|
+
local snippet = string.sub(line, math.max(1, s - 30), math.min(#line, e + 30))
|
|
5039
|
+
table.insert(threats, {
|
|
5040
|
+
severity = rule.severity,
|
|
5041
|
+
ruleId = rule.id,
|
|
5042
|
+
name = rule.name,
|
|
5043
|
+
scriptName = scriptInstance.Name,
|
|
5044
|
+
scriptPath = scriptInstance:GetFullName(),
|
|
5045
|
+
scriptClass = scriptInstance.ClassName,
|
|
5046
|
+
line = lineIdx,
|
|
5047
|
+
snippet = string.match(snippet, "^%s*(.-)%s*$") or snippet,
|
|
5048
|
+
description = rule.description,
|
|
5049
|
+
})
|
|
5050
|
+
end
|
|
5051
|
+
end
|
|
5052
|
+
end
|
|
5053
|
+
end
|
|
5054
|
+
|
|
5055
|
+
-- 2. Cek Obfuscation
|
|
5056
|
+
local isObf, obfReason = checkByteObfuscation(source)
|
|
5057
|
+
if isObf then
|
|
5058
|
+
table.insert(threats, {
|
|
5059
|
+
severity = "HIGH",
|
|
5060
|
+
ruleId = "BYTE_OBFUSCATION",
|
|
5061
|
+
name = "Byte / Hex Obfuscation Payload",
|
|
5062
|
+
scriptName = scriptInstance.Name,
|
|
5063
|
+
scriptPath = scriptInstance:GetFullName(),
|
|
5064
|
+
scriptClass = scriptInstance.ClassName,
|
|
5065
|
+
line = 1,
|
|
5066
|
+
snippet = string.sub(source, 1, 60) .. "...",
|
|
5067
|
+
description = obfReason or "Payload kode disamarkan menggunakan escape bytes.",
|
|
5068
|
+
})
|
|
5069
|
+
end
|
|
5070
|
+
|
|
5071
|
+
-- 3. Cek Off-screen Code Hiding
|
|
5072
|
+
local isHidden, hideReason = checkOffscreenHiding(source)
|
|
5073
|
+
if isHidden then
|
|
5074
|
+
table.insert(threats, {
|
|
5075
|
+
severity = "HIGH",
|
|
5076
|
+
ruleId = "OFFSCREEN_HIDING",
|
|
5077
|
+
name = "Off-Screen Hidden Code",
|
|
5078
|
+
scriptName = scriptInstance.Name,
|
|
5079
|
+
scriptPath = scriptInstance:GetFullName(),
|
|
5080
|
+
scriptClass = scriptInstance.ClassName,
|
|
5081
|
+
line = 1,
|
|
5082
|
+
snippet = "[Whitespace Padding Terdeteksi]",
|
|
5083
|
+
description = hideReason or "Kode disembunyikan menggunakan whitespace ekstrem.",
|
|
5084
|
+
})
|
|
5085
|
+
end
|
|
5086
|
+
|
|
5087
|
+
return threats
|
|
5088
|
+
end
|
|
5089
|
+
|
|
5090
|
+
-- ============================================================================
|
|
5091
|
+
-- PUBLIC API
|
|
5092
|
+
-- ============================================================================
|
|
5093
|
+
|
|
5094
|
+
-- Memindai Instance secara rekursif
|
|
5095
|
+
function SecurityScanner.scanInstance(rootInstance: Instance, deepScan: boolean?)
|
|
5096
|
+
local shouldDeepScan = (deepScan ~= false)
|
|
5097
|
+
local allThreats = {}
|
|
5098
|
+
local stats = {
|
|
5099
|
+
totalInstances = 1,
|
|
5100
|
+
models = 0,
|
|
5101
|
+
parts = 0,
|
|
5102
|
+
meshParts = 0,
|
|
5103
|
+
textures = 0,
|
|
5104
|
+
scripts = {
|
|
5105
|
+
total = 0,
|
|
5106
|
+
server = 0,
|
|
5107
|
+
localScript = 0,
|
|
5108
|
+
module = 0,
|
|
5109
|
+
},
|
|
5110
|
+
}
|
|
5111
|
+
|
|
5112
|
+
local function inspectItem(item: Instance)
|
|
5113
|
+
stats.totalInstances = stats.totalInstances + 1
|
|
5114
|
+
|
|
5115
|
+
if item:IsA("Model") then
|
|
5116
|
+
stats.models = stats.models + 1
|
|
5117
|
+
elseif item:IsA("MeshPart") then
|
|
5118
|
+
stats.meshParts = stats.meshParts + 1
|
|
5119
|
+
stats.parts = stats.parts + 1
|
|
5120
|
+
elseif item:IsA("BasePart") then
|
|
5121
|
+
stats.parts = stats.parts + 1
|
|
5122
|
+
elseif item:IsA("Texture") or item:IsA("Decal") or item:IsA("SurfaceAppearance") then
|
|
5123
|
+
stats.textures = stats.textures + 1
|
|
5124
|
+
end
|
|
5125
|
+
|
|
5126
|
+
if item:IsA("LuaSourceContainer") then
|
|
5127
|
+
stats.scripts.total = stats.scripts.total + 1
|
|
5128
|
+
if item:IsA("Script") then
|
|
5129
|
+
stats.scripts.server = stats.scripts.server + 1
|
|
5130
|
+
elseif item:IsA("LocalScript") then
|
|
5131
|
+
stats.scripts.localScript = stats.scripts.localScript + 1
|
|
5132
|
+
elseif item:IsA("ModuleScript") then
|
|
5133
|
+
stats.scripts.module = stats.scripts.module + 1
|
|
5134
|
+
end
|
|
5135
|
+
|
|
5136
|
+
-- Cek nama mencurigakan
|
|
5137
|
+
if checkSuspiciousName(item.Name) then
|
|
5138
|
+
table.insert(allThreats, {
|
|
5139
|
+
severity = "HIGH",
|
|
5140
|
+
ruleId = "SUSPICIOUS_SCRIPT_NAME",
|
|
5141
|
+
name = "Known Malicious Script Name Pattern",
|
|
5142
|
+
scriptName = item.Name,
|
|
5143
|
+
scriptPath = item:GetFullName(),
|
|
5144
|
+
scriptClass = item.ClassName,
|
|
5145
|
+
line = 0,
|
|
5146
|
+
snippet = item.Name,
|
|
5147
|
+
description = string.format("Nama skrip '%s' cocok dengan pola nama virus/backdoor klasik.", item.Name),
|
|
5148
|
+
})
|
|
5149
|
+
end
|
|
5150
|
+
|
|
5151
|
+
-- Deep scan isi source code
|
|
5152
|
+
if shouldDeepScan then
|
|
5153
|
+
local success, source = pcall(function()
|
|
5154
|
+
return (item :: any).Source
|
|
5155
|
+
end)
|
|
5156
|
+
if success and typeof(source) == "string" and #source > 0 then
|
|
5157
|
+
local scriptThreats = scanScriptSource(item, source)
|
|
5158
|
+
for _, t in ipairs(scriptThreats) do
|
|
5159
|
+
table.insert(allThreats, t)
|
|
5160
|
+
end
|
|
5161
|
+
end
|
|
5162
|
+
end
|
|
5163
|
+
end
|
|
5164
|
+
end
|
|
5165
|
+
|
|
5166
|
+
inspectItem(rootInstance)
|
|
5167
|
+
for _, desc in ipairs(rootInstance:GetDescendants()) do
|
|
5168
|
+
inspectItem(desc)
|
|
5169
|
+
end
|
|
5170
|
+
|
|
5171
|
+
-- Hitung Level Risiko
|
|
5172
|
+
local riskLevel = "SAFE"
|
|
5173
|
+
local highCount = 0
|
|
5174
|
+
local medCount = 0
|
|
5175
|
+
|
|
5176
|
+
for _, t in ipairs(allThreats) do
|
|
5177
|
+
if t.severity == "HIGH" then
|
|
5178
|
+
highCount = highCount + 1
|
|
5179
|
+
elseif t.severity == "MEDIUM" then
|
|
5180
|
+
medCount = medCount + 1
|
|
5181
|
+
end
|
|
5182
|
+
end
|
|
5183
|
+
|
|
5184
|
+
if highCount > 0 then
|
|
5185
|
+
riskLevel = "DANGEROUS"
|
|
5186
|
+
elseif medCount > 0 or stats.scripts.total > 0 then
|
|
5187
|
+
riskLevel = "WARNING"
|
|
5188
|
+
end
|
|
5189
|
+
|
|
5190
|
+
return {
|
|
5191
|
+
riskLevel = riskLevel,
|
|
5192
|
+
threats = allThreats,
|
|
5193
|
+
stats = stats,
|
|
5194
|
+
highThreats = highCount,
|
|
5195
|
+
mediumThreats = medCount,
|
|
5196
|
+
}
|
|
5197
|
+
end
|
|
5198
|
+
|
|
5199
|
+
-- Menghapus seluruh skrip dari sebuah instance (Sanitasi Total)
|
|
5200
|
+
function SecurityScanner.stripScripts(rootInstance: Instance): { any }
|
|
5201
|
+
local removed = {}
|
|
5202
|
+
|
|
5203
|
+
for _, desc in ipairs(rootInstance:GetDescendants()) do
|
|
5204
|
+
if desc:IsA("LuaSourceContainer") then
|
|
5205
|
+
table.insert(removed, {
|
|
5206
|
+
name = desc.Name,
|
|
5207
|
+
className = desc.ClassName,
|
|
5208
|
+
path = desc:GetFullName(),
|
|
5209
|
+
})
|
|
5210
|
+
pcall(function()
|
|
5211
|
+
desc:Destroy()
|
|
5212
|
+
end)
|
|
5213
|
+
end
|
|
5214
|
+
end
|
|
5215
|
+
|
|
5216
|
+
if rootInstance:IsA("LuaSourceContainer") then
|
|
5217
|
+
table.insert(removed, {
|
|
5218
|
+
name = rootInstance.Name,
|
|
5219
|
+
className = rootInstance.ClassName,
|
|
5220
|
+
path = rootInstance:GetFullName(),
|
|
5221
|
+
})
|
|
5222
|
+
pcall(function()
|
|
5223
|
+
rootInstance:Destroy()
|
|
5224
|
+
end)
|
|
5225
|
+
end
|
|
5226
|
+
|
|
5227
|
+
return removed
|
|
5228
|
+
end
|
|
5229
|
+
|
|
5230
|
+
return SecurityScanner
|
|
5231
|
+
|
|
1627
5232
|
]]></ProtectedString>
|
|
1628
5233
|
</Properties>
|
|
1629
5234
|
</Item>
|
|
@@ -1651,6 +5256,10 @@ end
|
|
|
1651
5256
|
local currentVal = targetInstance[prop]
|
|
1652
5257
|
if typeof(currentVal) == "Vector3" and type(val) == "table" and #val == 3 then
|
|
1653
5258
|
targetInstance[prop] = Vector3.new(val[1], val[2], val[3])
|
|
5259
|
+
elseif typeof(currentVal) == "Vector2" and type(val) == "table" and #val >= 2 then
|
|
5260
|
+
targetInstance[prop] = Vector2.new(val[1], val[2])
|
|
5261
|
+
elseif typeof(currentVal) == "UDim" and type(val) == "table" and #val >= 2 then
|
|
5262
|
+
targetInstance[prop] = UDim.new(val[1], val[2])
|
|
1654
5263
|
elseif typeof(currentVal) == "Color3" and type(val) == "table" and #val == 3 then
|
|
1655
5264
|
local r, g, b = val[1], val[2], val[3]
|
|
1656
5265
|
if r > 1 or g > 1 or b > 1 then
|
|
@@ -1660,8 +5269,87 @@ end
|
|
|
1660
5269
|
end
|
|
1661
5270
|
elseif typeof(currentVal) == "UDim2" and type(val) == "table" and #val == 4 then
|
|
1662
5271
|
targetInstance[prop] = UDim2.new(val[1], val[2], val[3], val[4])
|
|
5272
|
+
elseif typeof(currentVal) == "NumberRange" and type(val) == "table" then
|
|
5273
|
+
targetInstance[prop] = NumberRange.new(val[1], val[2] or val[1])
|
|
5274
|
+
elseif typeof(currentVal) == "Rect" and type(val) == "table" and #val >= 4 then
|
|
5275
|
+
targetInstance[prop] = Rect.new(val[1], val[2], val[3], val[4])
|
|
5276
|
+
elseif typeof(currentVal) == "BrickColor" and type(val) == "string" then
|
|
5277
|
+
targetInstance[prop] = BrickColor.new(val)
|
|
1663
5278
|
elseif typeof(currentVal) == "CFrame" and type(val) == "table" then
|
|
1664
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
|
|
1665
5353
|
elseif typeof(currentVal) == "EnumItem" and type(val) == "string" then
|
|
1666
5354
|
local enumType = tostring(currentVal.EnumType)
|
|
1667
5355
|
local successEnum, enumValue = pcall(function() return Enum[enumType][val] end)
|
|
@@ -1710,6 +5398,82 @@ end
|
|
|
1710
5398
|
end
|
|
1711
5399
|
return { status = "success", result = "Berhasil mengubah properti: " .. table.concat(applied, ", ") }
|
|
1712
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
|
+
|
|
1713
5477
|
]]></ProtectedString>
|
|
1714
5478
|
</Properties>
|
|
1715
5479
|
</Item>
|
|
@@ -1820,7 +5584,7 @@ function UIManager.init(pluginInstance, studioId)
|
|
|
1820
5584
|
|
|
1821
5585
|
-- Version
|
|
1822
5586
|
local versionLabel = Instance.new("TextLabel")
|
|
1823
|
-
versionLabel.Text = "v2.
|
|
5587
|
+
versionLabel.Text = "v2.4.0"
|
|
1824
5588
|
versionLabel.Font = Enum.Font.Gotham
|
|
1825
5589
|
versionLabel.TextSize = 11
|
|
1826
5590
|
versionLabel.TextColor3 = Color3.fromRGB(120, 120, 130)
|