@adiholick/nvstudio-mcp 2.3.0 → 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 +17 -3
- package/agent-plugin/rules/AGENTS.md +31 -0
- package/agent-plugin/skills/nvstudio-animation-vfx/SKILL.md +120 -0
- package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +14 -0
- package/agent-plugin/skills/nvstudio-rig-engine/SKILL.md +170 -0
- package/dist/index.js +625 -0
- package/dist/roblox-bridge.js +108 -0
- package/dist/vfx-database.js +507 -0
- package/package.json +2 -2
- package/public/index.html +913 -14
- package/studio-plugin/Main.server.lua +1 -1
- package/studio-plugin/animate_character.lua +36 -0
- package/studio-plugin/apply_vfx.lua +59 -0
- package/studio-plugin/edit_rig.lua +35 -0
- package/studio-plugin/nv_anim_dispatcher.lua +239 -0
- package/studio-plugin/nv_auto_rigger.lua +392 -0
- package/studio-plugin/nv_curves.lua +260 -0
- package/studio-plugin/nv_joint_math.lua +138 -0
- package/studio-plugin/nv_kinematics.lua +248 -0
- package/studio-plugin/nv_motion_generator.lua +279 -0
- package/studio-plugin/nv_rig_dispatcher.lua +319 -0
- package/studio-plugin/nv_vfx_serializer.lua +363 -0
- package/studio-plugin/nvstudio_mcp.rbxmx +3008 -115
- package/studio-plugin/scan_vfx.lua +85 -0
- package/studio-plugin/scrub_timeline.lua +122 -0
- package/studio-plugin/set_properties.lua +73 -0
- package/studio-plugin/solve_limb_ik.lua +63 -0
- package/studio-plugin/ui_manager.lua +1 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
-- NVStudio Subsystem: nv_rig_dispatcher
|
|
4
|
+
-- Deskripsi: Headless Modular Command Dispatcher untuk NV-Rig Engine
|
|
5
|
+
-- Mengelola routing perintah rigging dari AI Agent LLM, eksekusi
|
|
6
|
+
-- deterministik, inspeksi topologi rig, dan diagnostik otomatis.
|
|
7
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
8
|
+
|
|
9
|
+
local jointMathMod = script.Parent:WaitForChild("nv_joint_math")
|
|
10
|
+
local JointMath = require(jointMathMod)
|
|
11
|
+
local autoRiggerMod = script.Parent:WaitForChild("nv_auto_rigger")
|
|
12
|
+
local AutoRigger = require(autoRiggerMod)
|
|
13
|
+
|
|
14
|
+
local Dispatcher = {}
|
|
15
|
+
|
|
16
|
+
-- ── 1. Inspeksi & Diagnostik Rig Mendalam (Deep Topological Inspection) ────────
|
|
17
|
+
function Dispatcher.inspectRig(targetInstance: Instance): { [string]: any }
|
|
18
|
+
if not targetInstance then
|
|
19
|
+
return { status = "error", error = "Target instance tidak ditemukan." }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
local model = targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model")
|
|
23
|
+
if not model then
|
|
24
|
+
model = targetInstance:IsA("Folder") and targetInstance or targetInstance:IsA("BasePart") and targetInstance.Parent :: any
|
|
25
|
+
end
|
|
26
|
+
if not model then
|
|
27
|
+
return { status = "error", error = "Target harus berada di dalam Model atau Folder." }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
local parts: { [string]: { anchored: boolean, size: { number }, mass: number, className: string } } = {}
|
|
31
|
+
local joints: { any } = {}
|
|
32
|
+
local parentToChildren: { [string]: { string } } = {}
|
|
33
|
+
local childToParent: { [string]: string } = {}
|
|
34
|
+
local rootCandidates: { string } = {}
|
|
35
|
+
local anchoredCount = 0
|
|
36
|
+
local totalParts = 0
|
|
37
|
+
|
|
38
|
+
for _, desc in ipairs(model:GetDescendants()) do
|
|
39
|
+
if desc:IsA("BasePart") then
|
|
40
|
+
totalParts += 1
|
|
41
|
+
if desc.Anchored then anchoredCount += 1 end
|
|
42
|
+
parts[desc.Name] = {
|
|
43
|
+
anchored = desc.Anchored,
|
|
44
|
+
size = { desc.Size.X, desc.Size.Y, desc.Size.Z },
|
|
45
|
+
mass = desc:GetMass(),
|
|
46
|
+
className = desc.ClassName
|
|
47
|
+
}
|
|
48
|
+
elseif desc:IsA("Motor6D") or desc:IsA("Weld") then
|
|
49
|
+
local p0Name = desc.Part0 and desc.Part0.Name or "nil"
|
|
50
|
+
local p1Name = desc.Part1 and desc.Part1.Name or "nil"
|
|
51
|
+
local pivotWorld = JointMath.getJointWorldPosition(desc)
|
|
52
|
+
|
|
53
|
+
table.insert(joints, {
|
|
54
|
+
name = desc.Name,
|
|
55
|
+
type = desc.ClassName,
|
|
56
|
+
part0 = p0Name,
|
|
57
|
+
part1 = p1Name,
|
|
58
|
+
parent = desc.Parent and desc.Parent.Name or "nil",
|
|
59
|
+
pivotWorld = pivotWorld and { pivotWorld.X, pivotWorld.Y, pivotWorld.Z } or nil,
|
|
60
|
+
c0 = { desc.C0:GetComponents() },
|
|
61
|
+
c1 = { desc.C1:GetComponents() }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if p0Name ~= "nil" and p1Name ~= "nil" then
|
|
65
|
+
if not parentToChildren[p0Name] then parentToChildren[p0Name] = {} end
|
|
66
|
+
table.insert(parentToChildren[p0Name], p1Name)
|
|
67
|
+
childToParent[p1Name] = p0Name
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
-- Deteksi Root Part (Part yang tidak memiliki Part0 di atasnya)
|
|
73
|
+
for partName, _ in pairs(parts) do
|
|
74
|
+
if not childToParent[partName] then
|
|
75
|
+
table.insert(rootCandidates, partName)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
-- Deteksi Loop / Circular Dependency dalam hierarki
|
|
80
|
+
local hasCycle = false
|
|
81
|
+
local cyclePath = ""
|
|
82
|
+
local visited: { [string]: boolean } = {}
|
|
83
|
+
local recStack: { [string]: boolean } = {}
|
|
84
|
+
|
|
85
|
+
local function checkCycle(node: string, path: string): boolean
|
|
86
|
+
visited[node] = true
|
|
87
|
+
recStack[node] = true
|
|
88
|
+
local children = parentToChildren[node] or {}
|
|
89
|
+
for _, child in ipairs(children) do
|
|
90
|
+
if not visited[child] then
|
|
91
|
+
if checkCycle(child, path .. " -> " .. child) then return true end
|
|
92
|
+
elseif recStack[child] then
|
|
93
|
+
cyclePath = path .. " -> " .. child
|
|
94
|
+
return true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
recStack[node] = false
|
|
98
|
+
return false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
for _, root in ipairs(rootCandidates) do
|
|
102
|
+
if checkCycle(root, root) then
|
|
103
|
+
hasCycle = true
|
|
104
|
+
break
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
local humanoid = model:FindFirstChildOfClass("Humanoid")
|
|
109
|
+
local rigType = humanoid and (humanoid.RigType == Enum.HumanoidRigType.R15 and "R15" or "R6") or "Custom"
|
|
110
|
+
|
|
111
|
+
local warnings: { string } = {}
|
|
112
|
+
if hasCycle then
|
|
113
|
+
table.insert(warnings, "Ditemukan siklus tertutup (Circular Dependency): " .. cyclePath)
|
|
114
|
+
end
|
|
115
|
+
if anchoredCount > 1 then
|
|
116
|
+
table.insert(warnings, string.format("Ada %d part yang masih 'Anchored = true'. Karakter mungkin tidak dapat bergerak/beranimasi.", anchoredCount))
|
|
117
|
+
end
|
|
118
|
+
if #rootCandidates > 1 then
|
|
119
|
+
table.insert(warnings, "Ditemukan lebih dari 1 calon root part: " .. table.concat(rootCandidates, ", "))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
status = "success",
|
|
124
|
+
modelName = model.Name,
|
|
125
|
+
rigType = rigType,
|
|
126
|
+
totalParts = totalParts,
|
|
127
|
+
totalJoints = #joints,
|
|
128
|
+
rootParts = rootCandidates,
|
|
129
|
+
hasCycle = hasCycle,
|
|
130
|
+
warnings = warnings,
|
|
131
|
+
joints = joints,
|
|
132
|
+
parts = parts
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
-- ── 2. Buat Sambungan Sendi Baru (Create Joint / Weld) ─────────────────────────
|
|
137
|
+
function Dispatcher.createJoint(targetModel: Instance, params: { [string]: any }): { [string]: any }
|
|
138
|
+
local part0Name = params.part0
|
|
139
|
+
local part1Name = params.part1
|
|
140
|
+
local jointType = params.joint_type or "Motor6D"
|
|
141
|
+
local jointName = params.name
|
|
142
|
+
|
|
143
|
+
if not part0Name or not part1Name then
|
|
144
|
+
return { status = "error", error = "Parameter 'part0' dan 'part1' wajib disertakan." }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
local part0 = targetModel:FindFirstChild(part0Name, true) :: BasePart?
|
|
148
|
+
local part1 = targetModel:FindFirstChild(part1Name, true) :: BasePart?
|
|
149
|
+
|
|
150
|
+
if not part0 or not part0:IsA("BasePart") then
|
|
151
|
+
return { status = "error", error = "Part0 '" .. tostring(part0Name) .. "' tidak ditemukan." }
|
|
152
|
+
end
|
|
153
|
+
if not part1 or not part1:IsA("BasePart") then
|
|
154
|
+
return { status = "error", error = "Part1 '" .. tostring(part1Name) .. "' tidak ditemukan." }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
part1.Anchored = false
|
|
158
|
+
local joint: JointInstance
|
|
159
|
+
if jointType == "Weld" then
|
|
160
|
+
joint = Instance.new("Weld")
|
|
161
|
+
else
|
|
162
|
+
joint = Instance.new("Motor6D")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
joint.Name = jointName or (jointType == "Motor6D" and part1.Name or (part0.Name .. "_" .. part1.Name))
|
|
166
|
+
joint.Part0 = part0
|
|
167
|
+
joint.Part1 = part1
|
|
168
|
+
local c0, c1 = JointMath.computeInitialOffsets(part0, part1)
|
|
169
|
+
joint.C0 = c0
|
|
170
|
+
joint.C1 = c1
|
|
171
|
+
joint.Parent = part0
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
status = "success",
|
|
175
|
+
jointName = joint.Name,
|
|
176
|
+
jointType = joint.ClassName,
|
|
177
|
+
part0 = part0.Name,
|
|
178
|
+
part1 = part1.Name,
|
|
179
|
+
message = string.format("Berhasil membuat %s '%s' antara '%s' dan '%s'", joint.ClassName, joint.Name, part0.Name, part1.Name)
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
-- ── 3. Edit Poros Sendi (Offset World / Target Position) ───────────────────────
|
|
184
|
+
function Dispatcher.editPivot(targetModel: Instance, params: { [string]: any }): { [string]: any }
|
|
185
|
+
local jointName = params.joint_name or params.name
|
|
186
|
+
if not jointName then
|
|
187
|
+
return { status = "error", error = "Parameter 'joint_name' wajib diisi." }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
local joint: JointInstance? = nil
|
|
191
|
+
for _, desc in ipairs(targetModel:GetDescendants()) do
|
|
192
|
+
if (desc:IsA("Motor6D") or desc:IsA("Weld")) and desc.Name == jointName then
|
|
193
|
+
joint = desc
|
|
194
|
+
break
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
if not joint then
|
|
199
|
+
return { status = "error", error = "Joint '" .. tostring(jointName) .. "' tidak ditemukan pada model." }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
local success = false
|
|
203
|
+
if params.delta_world and type(params.delta_world) == "table" and #params.delta_world >= 3 then
|
|
204
|
+
local d = params.delta_world
|
|
205
|
+
success = JointMath.offsetPivot(joint, Vector3.new(d[1], d[2], d[3]))
|
|
206
|
+
elseif params.target_world and type(params.target_world) == "table" and #params.target_world >= 3 then
|
|
207
|
+
local p = params.target_world
|
|
208
|
+
success = JointMath.setPivotWorld(joint, Vector3.new(p[1], p[2], p[3]))
|
|
209
|
+
elseif params.rotate_euler_deg and type(params.rotate_euler_deg) == "table" and #params.rotate_euler_deg >= 3 then
|
|
210
|
+
local r = params.rotate_euler_deg
|
|
211
|
+
local rad = Vector3.new(math.rad(r[1]), math.rad(r[2]), math.rad(r[3]))
|
|
212
|
+
success = JointMath.rotatePivot(joint, rad)
|
|
213
|
+
elseif params.reset == true then
|
|
214
|
+
success = JointMath.resetPivot(joint)
|
|
215
|
+
elseif params.align_to == "Part0" or params.align_to == "Part1" then
|
|
216
|
+
success = JointMath.alignJoint(joint, params.align_to)
|
|
217
|
+
else
|
|
218
|
+
return { status = "error", error = "Tindakan edit pivot tidak spesifik. Berikan delta_world, target_world, rotate_euler_deg, reset, atau align_to." }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if not success then
|
|
222
|
+
return { status = "error", error = "Gagal memodifikasi poros sendi '" .. jointName .. "'." }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
local newPivot = JointMath.getJointWorldPosition(joint)
|
|
226
|
+
return {
|
|
227
|
+
status = "success",
|
|
228
|
+
jointName = joint.Name,
|
|
229
|
+
newPivotWorld = newPivot and { newPivot.X, newPivot.Y, newPivot.Z } or nil,
|
|
230
|
+
newC0 = { joint.C0:GetComponents() },
|
|
231
|
+
newC1 = { joint.C1:GetComponents() },
|
|
232
|
+
message = "Poros sendi '" .. joint.Name .. "' berhasil diperbarui tanpa dislokasi part."
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
-- ── 4. Cerminkan Sendi Simetri Bilateral (Mirror Joints) ──────────────────────
|
|
237
|
+
function Dispatcher.mirrorJoints(targetModel: Instance, params: { [string]: any }): { [string]: any }
|
|
238
|
+
local sourceSide = params.source_side or "Left"
|
|
239
|
+
local targetSide = sourceSide == "Left" and "Right" or "Left"
|
|
240
|
+
|
|
241
|
+
local mirroredCount = 0
|
|
242
|
+
local pairsProcessed: { string } = {}
|
|
243
|
+
|
|
244
|
+
for _, desc in ipairs(targetModel:GetDescendants()) do
|
|
245
|
+
if desc:IsA("Motor6D") or desc:IsA("Weld") then
|
|
246
|
+
if string.find(desc.Name, sourceSide) then
|
|
247
|
+
local targetName = string.gsub(desc.Name, sourceSide, targetSide)
|
|
248
|
+
local targetJoint: JointInstance? = nil
|
|
249
|
+
for _, d2 in ipairs(targetModel:GetDescendants()) do
|
|
250
|
+
if (d2:IsA("Motor6D") or d2:IsA("Weld")) and d2.Name == targetName then
|
|
251
|
+
targetJoint = d2
|
|
252
|
+
break
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
if targetJoint then
|
|
257
|
+
JointMath.mirrorJoint(desc, targetJoint)
|
|
258
|
+
mirroredCount += 1
|
|
259
|
+
table.insert(pairsProcessed, desc.Name .. " -> " .. targetName)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
status = "success",
|
|
267
|
+
sourceSide = sourceSide,
|
|
268
|
+
targetSide = targetSide,
|
|
269
|
+
mirroredCount = mirroredCount,
|
|
270
|
+
pairs = pairsProcessed,
|
|
271
|
+
message = string.format("Berhasil mencerminkan %d sendi dari %s ke %s", mirroredCount, sourceSide, targetSide)
|
|
272
|
+
}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
-- ── 5. Router Eksekusi Utama (Modular Dispatcher) ─────────────────────────────
|
|
276
|
+
function Dispatcher.dispatch(targetInstance: Instance, action: string, data: { [string]: any }): { [string]: any }
|
|
277
|
+
if action == "inspect" then
|
|
278
|
+
return Dispatcher.inspectRig(targetInstance)
|
|
279
|
+
elseif action == "create_joint" or action == "create" then
|
|
280
|
+
return Dispatcher.createJoint(targetInstance, data)
|
|
281
|
+
elseif action == "create_weld" then
|
|
282
|
+
data.joint_type = "Weld"
|
|
283
|
+
return Dispatcher.createJoint(targetInstance, data)
|
|
284
|
+
elseif action == "edit_pivot" or action == "edit" or action == "move_pivot" or action == "rotate_pivot" or action == "reset_pivot" or action == "align_joint" then
|
|
285
|
+
return Dispatcher.editPivot(targetInstance, data)
|
|
286
|
+
elseif action == "mirror" or action == "mirror_joints" then
|
|
287
|
+
return Dispatcher.mirrorJoints(targetInstance, data)
|
|
288
|
+
elseif action == "auto_rig_r6" or action == "rig_r6" then
|
|
289
|
+
return AutoRigger.rigR6(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
|
|
290
|
+
elseif action == "auto_rig_r15" or action == "rig_r15" then
|
|
291
|
+
return AutoRigger.rigR15(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
|
|
292
|
+
elseif action == "rig_weapon" or action == "attach_weapon" then
|
|
293
|
+
local weapon = data.weapon_path and game:GetService("HttpService") -- akan di-resolve oleh caller jika ada path
|
|
294
|
+
return AutoRigger.rigWeapon(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any, targetInstance, data.target_limb)
|
|
295
|
+
elseif action == "weld_assembly" or action == "weld_all" then
|
|
296
|
+
return AutoRigger.weldAssembly(targetInstance, nil)
|
|
297
|
+
elseif action == "remove_joint" or action == "delete_joint" then
|
|
298
|
+
local jointName = data.joint_name or data.name
|
|
299
|
+
local removed = 0
|
|
300
|
+
for _, desc in ipairs(targetInstance:GetDescendants()) do
|
|
301
|
+
if (desc:IsA("Motor6D") or desc:IsA("Weld")) and (not jointName or desc.Name == jointName) then
|
|
302
|
+
if data.reanchor == true and desc.Part1 then
|
|
303
|
+
desc.Part1.Anchored = true
|
|
304
|
+
end
|
|
305
|
+
desc:Destroy()
|
|
306
|
+
removed += 1
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
return { status = "success", removedCount = removed, message = string.format("Berhasil menghapus %d joint", removed) }
|
|
310
|
+
else
|
|
311
|
+
return {
|
|
312
|
+
status = "error",
|
|
313
|
+
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."
|
|
314
|
+
}
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
return Dispatcher
|
|
319
|
+
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
-- NVStudio Subsystem: nv_vfx_serializer
|
|
4
|
+
-- Deskripsi: Serializer & Deserializer 1:1 Efek Visual Roblox
|
|
5
|
+
-- Mengonversi seluruh properti ParticleEmitter, Beam, Trail, Light,
|
|
6
|
+
-- dan Attachment menjadi tabel data murni (JSON-safe) dan merekonstruksinya
|
|
7
|
+
-- dengan fidelitas 100% pada proyek Roblox mana saja.
|
|
8
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
9
|
+
|
|
10
|
+
local Serializer = {}
|
|
11
|
+
|
|
12
|
+
-- ── 1. Property Whitelist Per ClassName ───────────────────────────────────────
|
|
13
|
+
local PROPERTY_WHITELISTS: { [string]: { string } } = {
|
|
14
|
+
ParticleEmitter = {
|
|
15
|
+
"Name", "Enabled", "Texture", "Color", "Size", "Transparency",
|
|
16
|
+
"Acceleration", "Drag", "EmissionDirection", "Lifetime",
|
|
17
|
+
"LightEmission", "LightInfluence", "Orientation", "Rate",
|
|
18
|
+
"RotSpeed", "Rotation", "Shape", "ShapeInOut", "ShapePartial",
|
|
19
|
+
"ShapeStyle", "Speed", "SpreadAngle", "Squash",
|
|
20
|
+
"FlipbookFramerate", "FlipbookLayout", "FlipbookMode", "FlipbookStartRandom",
|
|
21
|
+
"ZOffset", "Brightness", "TimeScale", "VelocityInheritance", "WindAffects"
|
|
22
|
+
},
|
|
23
|
+
Beam = {
|
|
24
|
+
"Name", "Enabled", "Attachment0", "Attachment1", "Color",
|
|
25
|
+
"CurveSize0", "CurveSize1", "FaceCamera", "LightEmission",
|
|
26
|
+
"LightInfluence", "Segments", "Texture", "TextureLength",
|
|
27
|
+
"TextureMode", "TextureSpeed", "Transparency", "Width0",
|
|
28
|
+
"Width1", "ZOffset", "Brightness"
|
|
29
|
+
},
|
|
30
|
+
Trail = {
|
|
31
|
+
"Name", "Enabled", "Attachment0", "Attachment1", "Color",
|
|
32
|
+
"FaceCamera", "LightEmission", "LightInfluence", "Lifetime",
|
|
33
|
+
"MaxLength", "MinLength", "Texture", "TextureLength",
|
|
34
|
+
"TextureMode", "Transparency", "WidthScale", "Brightness"
|
|
35
|
+
},
|
|
36
|
+
Attachment = {
|
|
37
|
+
"Name", "Visible", "CFrame"
|
|
38
|
+
},
|
|
39
|
+
PointLight = {
|
|
40
|
+
"Name", "Enabled", "Brightness", "Color", "Range", "Shadows"
|
|
41
|
+
},
|
|
42
|
+
SpotLight = {
|
|
43
|
+
"Name", "Enabled", "Brightness", "Color", "Range", "Shadows", "Angle", "Face"
|
|
44
|
+
},
|
|
45
|
+
SurfaceLight = {
|
|
46
|
+
"Name", "Enabled", "Brightness", "Color", "Range", "Shadows", "Face"
|
|
47
|
+
},
|
|
48
|
+
Highlight = {
|
|
49
|
+
"Name", "Enabled", "FillColor", "OutlineColor", "FillTransparency", "OutlineTransparency", "DepthMode"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
-- ── 2. Helper: Encode Roblox Values ke Pure Table ─────────────────────────────
|
|
54
|
+
local function encodeValue(val: any, nodeIndexMap: { [Instance]: number }): any
|
|
55
|
+
local valType = typeof(val)
|
|
56
|
+
|
|
57
|
+
if valType == "nil" or valType == "string" or valType == "number" or valType == "boolean" then
|
|
58
|
+
return val
|
|
59
|
+
elseif valType == "Vector3" then
|
|
60
|
+
return { t = "Vector3", v = { val.X, val.Y, val.Z } }
|
|
61
|
+
elseif valType == "Vector2" then
|
|
62
|
+
return { t = "Vector2", v = { val.X, val.Y } }
|
|
63
|
+
elseif valType == "CFrame" then
|
|
64
|
+
return { t = "CFrame", v = { val:GetComponents() } }
|
|
65
|
+
elseif valType == "Color3" then
|
|
66
|
+
return { t = "Color3", v = { val.R, val.G, val.B } }
|
|
67
|
+
elseif valType == "NumberRange" then
|
|
68
|
+
return { t = "NumberRange", v = { val.Min, val.Max } }
|
|
69
|
+
elseif valType == "NumberSequence" then
|
|
70
|
+
local kps = {}
|
|
71
|
+
for _, kp in ipairs(val.Keypoints) do
|
|
72
|
+
table.insert(kps, { kp.Time, kp.Value, kp.Envelope })
|
|
73
|
+
end
|
|
74
|
+
return { t = "NumberSequence", v = kps }
|
|
75
|
+
elseif valType == "ColorSequence" then
|
|
76
|
+
local kps = {}
|
|
77
|
+
for _, kp in ipairs(val.Keypoints) do
|
|
78
|
+
table.insert(kps, {
|
|
79
|
+
kp.Time,
|
|
80
|
+
{ t = "Color3", v = { kp.Value.R, kp.Value.G, kp.Value.B } }
|
|
81
|
+
})
|
|
82
|
+
end
|
|
83
|
+
return { t = "ColorSequence", v = kps }
|
|
84
|
+
elseif valType == "EnumItem" then
|
|
85
|
+
return { t = "EnumItem", e = tostring(val.EnumType), v = val.Name }
|
|
86
|
+
elseif valType == "Instance" then
|
|
87
|
+
local idx = nodeIndexMap[val]
|
|
88
|
+
if idx then
|
|
89
|
+
return { t = "Ref", v = idx }
|
|
90
|
+
end
|
|
91
|
+
return { t = "InstanceName", v = val.Name }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
return tostring(val)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
-- ── 3. Helper: Decode Pure Table ke Roblox Values ──────────────────────────────
|
|
98
|
+
local function decodeValue(val: any, createdNodes: { [number]: Instance }, scale: number?): any
|
|
99
|
+
if type(val) ~= "table" or not val.t then
|
|
100
|
+
return val
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
local t = val.t
|
|
104
|
+
local v = val.v
|
|
105
|
+
local s = scale or 1
|
|
106
|
+
|
|
107
|
+
if t == "Vector3" then
|
|
108
|
+
return Vector3.new(v[1] * s, v[2] * s, v[3] * s)
|
|
109
|
+
elseif t == "Vector2" then
|
|
110
|
+
return Vector2.new(v[1], v[2])
|
|
111
|
+
elseif t == "CFrame" then
|
|
112
|
+
local c = v
|
|
113
|
+
if s ~= 1 then
|
|
114
|
+
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])
|
|
115
|
+
end
|
|
116
|
+
return CFrame.new(table.unpack(c))
|
|
117
|
+
elseif t == "Color3" then
|
|
118
|
+
return Color3.new(v[1], v[2], v[3])
|
|
119
|
+
elseif t == "NumberRange" then
|
|
120
|
+
local minV = tonumber(v[1]) or 0
|
|
121
|
+
local maxV = tonumber(v[2]) or minV
|
|
122
|
+
return NumberRange.new(math.min(minV, maxV), math.max(minV, maxV))
|
|
123
|
+
elseif t == "NumberSequence" then
|
|
124
|
+
local kps = {}
|
|
125
|
+
for _, kp in ipairs(v) do
|
|
126
|
+
if type(kp) == "table" then
|
|
127
|
+
local timeVal = math.clamp(tonumber(kp[1]) or 0, 0, 1)
|
|
128
|
+
local numVal = (tonumber(kp[2]) or 0) * s
|
|
129
|
+
local envVal = (tonumber(kp[3]) or 0) * s
|
|
130
|
+
table.insert(kps, NumberSequenceKeypoint.new(timeVal, numVal, envVal))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
if #kps >= 2 then
|
|
134
|
+
table.sort(kps, function(a, b) return a.Time < b.Time end)
|
|
135
|
+
if kps[1].Time ~= 0 then
|
|
136
|
+
kps[1] = NumberSequenceKeypoint.new(0, kps[1].Value, kps[1].Envelope)
|
|
137
|
+
end
|
|
138
|
+
if kps[#kps].Time ~= 1 then
|
|
139
|
+
kps[#kps] = NumberSequenceKeypoint.new(1, kps[#kps].Value, kps[#kps].Envelope)
|
|
140
|
+
end
|
|
141
|
+
for i = 2, #kps do
|
|
142
|
+
if kps[i].Time <= kps[i - 1].Time then
|
|
143
|
+
local newT = math.min(1.0, kps[i - 1].Time + 0.001)
|
|
144
|
+
kps[i] = NumberSequenceKeypoint.new(newT, kps[i].Value, kps[i].Envelope)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
return NumberSequence.new(kps)
|
|
148
|
+
elseif #kps == 1 then
|
|
149
|
+
return NumberSequence.new(kps[1].Value)
|
|
150
|
+
end
|
|
151
|
+
return NumberSequence.new(0)
|
|
152
|
+
elseif t == "ColorSequence" then
|
|
153
|
+
local kps = {}
|
|
154
|
+
for _, kp in ipairs(v) do
|
|
155
|
+
if type(kp) == "table" and kp[2] then
|
|
156
|
+
local timeVal = math.clamp(tonumber(kp[1]) or 0, 0, 1)
|
|
157
|
+
local col = kp[2].v or { 1, 1, 1 }
|
|
158
|
+
table.insert(kps, ColorSequenceKeypoint.new(timeVal, Color3.new(col[1] or 1, col[2] or 1, col[3] or 1)))
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
if #kps >= 2 then
|
|
162
|
+
table.sort(kps, function(a, b) return a.Time < b.Time end)
|
|
163
|
+
if kps[1].Time ~= 0 then
|
|
164
|
+
kps[1] = ColorSequenceKeypoint.new(0, kps[1].Value)
|
|
165
|
+
end
|
|
166
|
+
if kps[#kps].Time ~= 1 then
|
|
167
|
+
kps[#kps] = ColorSequenceKeypoint.new(1, kps[#kps].Value)
|
|
168
|
+
end
|
|
169
|
+
for i = 2, #kps do
|
|
170
|
+
if kps[i].Time <= kps[i - 1].Time then
|
|
171
|
+
local newT = math.min(1.0, kps[i - 1].Time + 0.001)
|
|
172
|
+
kps[i] = ColorSequenceKeypoint.new(newT, kps[i].Value)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
return ColorSequence.new(kps)
|
|
176
|
+
elseif #kps == 1 then
|
|
177
|
+
return ColorSequence.new(kps[1].Value)
|
|
178
|
+
end
|
|
179
|
+
return ColorSequence.new(Color3.new(1, 1, 1))
|
|
180
|
+
elseif t == "EnumItem" then
|
|
181
|
+
local enumGroup = Enum[val.e]
|
|
182
|
+
if enumGroup and enumGroup[v] then
|
|
183
|
+
return enumGroup[v]
|
|
184
|
+
end
|
|
185
|
+
return nil
|
|
186
|
+
elseif t == "Ref" then
|
|
187
|
+
return createdNodes[v]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
return val
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
-- ── 4. Serializer: Mengonversi Instance Hierarchy Menjadi Pure Table ─────────
|
|
194
|
+
function Serializer.serialize(rootInstance: Instance): { [string]: any }
|
|
195
|
+
assert(typeof(rootInstance) == "Instance", "Root instance harus berupa Roblox Instance")
|
|
196
|
+
|
|
197
|
+
-- Kumpulkan seluruh node VFX yang didukung
|
|
198
|
+
local rawNodes: { Instance } = {}
|
|
199
|
+
local nodeIndexMap: { [Instance]: number } = {}
|
|
200
|
+
|
|
201
|
+
local function collect(inst: Instance)
|
|
202
|
+
if PROPERTY_WHITELISTS[inst.ClassName] or inst:IsA("Folder") or inst:IsA("Model") or inst:IsA("Configuration") or inst == rootInstance then
|
|
203
|
+
table.insert(rawNodes, inst)
|
|
204
|
+
nodeIndexMap[inst] = #rawNodes
|
|
205
|
+
end
|
|
206
|
+
for _, child in ipairs(inst:GetChildren()) do
|
|
207
|
+
collect(child)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
collect(rootInstance)
|
|
212
|
+
|
|
213
|
+
local nodes = {}
|
|
214
|
+
local stats = { emitters = 0, beams = 0, trails = 0, lights = 0 }
|
|
215
|
+
|
|
216
|
+
for i, inst in ipairs(rawNodes) do
|
|
217
|
+
local className = inst.ClassName
|
|
218
|
+
local parentIndex = nil
|
|
219
|
+
if inst ~= rootInstance and inst.Parent then
|
|
220
|
+
parentIndex = nodeIndexMap[inst.Parent]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
local props = {}
|
|
224
|
+
local whitelist = PROPERTY_WHITELISTS[className]
|
|
225
|
+
if whitelist then
|
|
226
|
+
for _, propName in ipairs(whitelist) do
|
|
227
|
+
local ok, propVal = pcall(function() return (inst :: any)[propName] end)
|
|
228
|
+
if ok and propVal ~= nil then
|
|
229
|
+
props[propName] = encodeValue(propVal, nodeIndexMap)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
else
|
|
233
|
+
props["Name"] = inst.Name
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
-- Perbarui statistik
|
|
237
|
+
if className == "ParticleEmitter" then
|
|
238
|
+
stats.emitters += 1
|
|
239
|
+
elseif className == "Beam" then
|
|
240
|
+
stats.beams += 1
|
|
241
|
+
elseif className == "Trail" then
|
|
242
|
+
stats.trails += 1
|
|
243
|
+
elseif className:find("Light") then
|
|
244
|
+
stats.lights += 1
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
nodes[i] = {
|
|
248
|
+
class = className,
|
|
249
|
+
name = inst.Name,
|
|
250
|
+
parent = parentIndex,
|
|
251
|
+
properties = props
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
version = 1,
|
|
257
|
+
rootName = rootInstance.Name,
|
|
258
|
+
rootClass = rootInstance.ClassName,
|
|
259
|
+
nodeCount = #nodes,
|
|
260
|
+
stats = stats,
|
|
261
|
+
nodes = nodes
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
-- ── 5. Deserializer: Merekonstruksi Roblox Instances dari Pure Table ───────────
|
|
266
|
+
function Serializer.deserialize(treeData: { [string]: any }, targetParent: Instance, options: { scale: number?, enabled: boolean? }?): { [string]: any }
|
|
267
|
+
assert(type(treeData) == "table" and treeData.nodes, "Format data treeData tidak valid")
|
|
268
|
+
assert(typeof(targetParent) == "Instance", "TargetParent harus berupa Instance")
|
|
269
|
+
|
|
270
|
+
local opts = options or {}
|
|
271
|
+
local scale = tonumber(opts.scale) or 1
|
|
272
|
+
local createdNodes: { [number]: Instance } = {}
|
|
273
|
+
local refDeferred: { { inst: Instance, propName: string, refIdx: number } } = {}
|
|
274
|
+
local errors: { string } = {}
|
|
275
|
+
local createdCount = 0
|
|
276
|
+
|
|
277
|
+
-- Pass 1: Buat semua instance dan tetapkan parent dasar
|
|
278
|
+
for i, node in ipairs(treeData.nodes) do
|
|
279
|
+
local ok, inst = pcall(function()
|
|
280
|
+
-- Jika node pertama adalah root dan bertipe Part/Model pembungkus, kita bisa menaruh isinya langsung
|
|
281
|
+
if i == 1 and (node.class == "Folder" or node.class == "Model" or node.class == "Configuration") and targetParent:IsA("BasePart") then
|
|
282
|
+
-- Buat container pembungkus di targetParent
|
|
283
|
+
local container = Instance.new(node.class)
|
|
284
|
+
container.Name = node.name or "NV_VFX"
|
|
285
|
+
container.Parent = targetParent
|
|
286
|
+
return container
|
|
287
|
+
elseif i == 1 and (node.class == "ParticleEmitter" or node.class == "Beam" or node.class == "Trail" or node.class == "Attachment") then
|
|
288
|
+
local newInst = Instance.new(node.class)
|
|
289
|
+
newInst.Name = node.name or "NV_VFX"
|
|
290
|
+
newInst.Parent = targetParent
|
|
291
|
+
return newInst
|
|
292
|
+
else
|
|
293
|
+
local newInst = Instance.new(node.class)
|
|
294
|
+
newInst.Name = node.name or ("Node_" .. tostring(i))
|
|
295
|
+
return newInst
|
|
296
|
+
end
|
|
297
|
+
end)
|
|
298
|
+
|
|
299
|
+
if ok and inst then
|
|
300
|
+
createdNodes[i] = inst
|
|
301
|
+
createdCount += 1
|
|
302
|
+
else
|
|
303
|
+
table.insert(errors, "Gagal membuat instance untuk node " .. tostring(i) .. ": " .. tostring(inst))
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
-- Pass 2: Hubungkan hierarki Parent
|
|
308
|
+
for i, node in ipairs(treeData.nodes) do
|
|
309
|
+
local inst = createdNodes[i]
|
|
310
|
+
if inst and i > 1 then
|
|
311
|
+
local parentIdx = node.parent
|
|
312
|
+
if parentIdx and createdNodes[parentIdx] then
|
|
313
|
+
inst.Parent = createdNodes[parentIdx]
|
|
314
|
+
else
|
|
315
|
+
inst.Parent = createdNodes[1] or targetParent
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
-- Pass 3: Terapkan Properti (dengan scaling & deferred Attachment ref)
|
|
321
|
+
for i, node in ipairs(treeData.nodes) do
|
|
322
|
+
local inst = createdNodes[i]
|
|
323
|
+
if inst and node.properties then
|
|
324
|
+
for propName, propData in pairs(node.properties) do
|
|
325
|
+
if type(propData) == "table" and propData.t == "Ref" then
|
|
326
|
+
table.insert(refDeferred, { inst = inst, propName = propName, refIdx = propData.v })
|
|
327
|
+
else
|
|
328
|
+
local pScale = (propName == "Size" or propName == "Width0" or propName == "Width1" or propName == "Speed" or propName == "Acceleration") and scale or 1
|
|
329
|
+
local decoded = decodeValue(propData, createdNodes, pScale)
|
|
330
|
+
if decoded ~= nil then
|
|
331
|
+
pcall(function()
|
|
332
|
+
if propName == "Enabled" and opts.enabled ~= nil then
|
|
333
|
+
(inst :: any)[propName] = opts.enabled
|
|
334
|
+
else
|
|
335
|
+
(inst :: any)[propName] = decoded
|
|
336
|
+
end
|
|
337
|
+
end)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
-- Pass 4: Hubungkan Deferred Attachment References (Beam / Trail)
|
|
345
|
+
for _, ref in ipairs(refDeferred) do
|
|
346
|
+
local targetNode = createdNodes[ref.refIdx]
|
|
347
|
+
if targetNode and targetNode:IsA("Attachment") then
|
|
348
|
+
pcall(function()
|
|
349
|
+
(ref.inst :: any)[ref.propName] = targetNode
|
|
350
|
+
end)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
status = #errors == 0 and "success" or "partial",
|
|
356
|
+
createdCount = createdCount,
|
|
357
|
+
root = createdNodes[1] or targetParent,
|
|
358
|
+
errors = errors
|
|
359
|
+
}
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
return Serializer
|
|
363
|
+
|