@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.
@@ -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
+