using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Tools { /// /// Single tool for ScriptableObject workflows: /// - action=create: create a ScriptableObject asset (and optionally apply patches) /// - action=modify: apply serialized property patches to an existing asset /// /// Patching is performed via SerializedObject/SerializedProperty paths (Unity-native), not reflection. /// [McpForUnityTool("manage_scriptable_object", AutoRegister = false)] public static class ManageScriptableObject { private const string CodeCompilingOrReloading = "compiling_or_reloading"; private const string CodeInvalidParams = "invalid_params"; private const string CodeTypeNotFound = "type_not_found"; private const string CodeInvalidFolderPath = "invalid_folder_path"; private const string CodeTargetNotFound = "target_not_found"; private const string CodeAssetCreateFailed = "asset_create_failed"; private static readonly HashSet ValidActions = new(StringComparer.OrdinalIgnoreCase) { // NOTE: Action strings are normalized by NormalizeAction() (lowercased, '_'/'-' removed), // so we only need the canonical normalized forms here. "create", "createso", "modify", "modifyso", }; public static object HandleCommand(JObject @params) { if (@params == null) { return new ErrorResponse(CodeInvalidParams); } if (EditorApplication.isCompiling || EditorApplication.isUpdating) { // Unity is transient; treat as retryable on the client side. return new ErrorResponse(CodeCompilingOrReloading, new { hint = "retry" }); } // Allow JSON-string parameters for objects/arrays. JsonUtil.CoerceJsonStringParameter(@params, "target"); CoerceJsonStringArrayParameter(@params, "patches"); string actionRaw = @params["action"]?.ToString(); if (string.IsNullOrWhiteSpace(actionRaw)) { return new ErrorResponse(CodeInvalidParams, new { message = "'action' is required.", validActions = ValidActions.ToArray() }); } string action = NormalizeAction(actionRaw); if (!ValidActions.Contains(action)) { return new ErrorResponse(CodeInvalidParams, new { message = $"Unknown action: '{actionRaw}'.", validActions = ValidActions.ToArray() }); } if (IsCreateAction(action)) { return HandleCreate(@params); } return HandleModify(@params); } private static object HandleCreate(JObject @params) { string typeName = @params["typeName"]?.ToString() ?? @params["type_name"]?.ToString(); string folderPath = @params["folderPath"]?.ToString() ?? @params["folder_path"]?.ToString(); string assetName = @params["assetName"]?.ToString() ?? @params["asset_name"]?.ToString(); bool overwrite = @params["overwrite"]?.ToObject() ?? false; if (string.IsNullOrWhiteSpace(typeName)) { return new ErrorResponse(CodeInvalidParams, new { message = "'typeName' is required." }); } if (string.IsNullOrWhiteSpace(folderPath)) { return new ErrorResponse(CodeInvalidParams, new { message = "'folderPath' is required." }); } if (string.IsNullOrWhiteSpace(assetName)) { return new ErrorResponse(CodeInvalidParams, new { message = "'assetName' is required." }); } if (assetName.Contains("/") || assetName.Contains("\\")) { return new ErrorResponse(CodeInvalidParams, new { message = "'assetName' must not contain path separators." }); } if (!TryNormalizeFolderPath(folderPath, out var normalizedFolder, out var folderNormalizeError)) { return new ErrorResponse(CodeInvalidFolderPath, new { message = folderNormalizeError, folderPath }); } if (!EnsureFolderExists(normalizedFolder, out var folderError)) { return new ErrorResponse(CodeInvalidFolderPath, new { message = folderError, folderPath = normalizedFolder }); } var resolvedType = ResolveType(typeName); if (resolvedType == null || !typeof(ScriptableObject).IsAssignableFrom(resolvedType)) { return new ErrorResponse(CodeTypeNotFound, new { message = $"ScriptableObject type not found: '{typeName}'", typeName }); } string fileName = assetName.EndsWith(".asset", StringComparison.OrdinalIgnoreCase) ? assetName : assetName + ".asset"; string desiredPath = $"{normalizedFolder.TrimEnd('/')}/{fileName}"; string finalPath = overwrite ? desiredPath : AssetDatabase.GenerateUniqueAssetPath(desiredPath); ScriptableObject instance; try { instance = ScriptableObject.CreateInstance(resolvedType); if (instance == null) { return new ErrorResponse(CodeAssetCreateFailed, new { message = "CreateInstance returned null.", typeName = resolvedType.FullName }); } } catch (Exception ex) { return new ErrorResponse(CodeAssetCreateFailed, new { message = ex.Message, typeName = resolvedType.FullName }); } // GUID-preserving overwrite logic bool isNewAsset = true; try { if (overwrite) { var existingAsset = AssetDatabase.LoadAssetAtPath(finalPath); if (existingAsset != null && existingAsset.GetType() == resolvedType) { // Preserve GUID by overwriting existing asset data in-place EditorUtility.CopySerialized(instance, existingAsset); // Fix for "Main Object Name does not match filename" warning: // CopySerialized overwrites the name with the (empty) name of the new instance. // We must restore the correct name to match the filename. existingAsset.name = Path.GetFileNameWithoutExtension(finalPath); UnityEngine.Object.DestroyImmediate(instance); // Destroy temporary instance instance = existingAsset; // Proceed with patching the existing asset isNewAsset = false; // Mark dirty to ensure changes are picked up EditorUtility.SetDirty(instance); } else if (existingAsset != null) { // Type mismatch or not a ScriptableObject - must delete and recreate to change type, losing GUID // (Or we could warn, but overwrite usually implies replacing) AssetDatabase.DeleteAsset(finalPath); } } if (isNewAsset) { // Ensure the new instance has the correct name before creating asset to avoid warnings instance.name = Path.GetFileNameWithoutExtension(finalPath); AssetDatabase.CreateAsset(instance, finalPath); } } catch (Exception ex) { return new ErrorResponse(CodeAssetCreateFailed, new { message = ex.Message, path = finalPath }); } string guid = AssetDatabase.AssetPathToGUID(finalPath); var patchesToken = @params["patches"]; object patchResults = null; var warnings = new List(); if (patchesToken is JArray patches && patches.Count > 0) { var patchApply = ApplyPatches(instance, patches); patchResults = patchApply.results; warnings.AddRange(patchApply.warnings); } EditorUtility.SetDirty(instance); AssetDatabase.SaveAssets(); return new SuccessResponse( "ScriptableObject created.", new { guid, path = finalPath, typeNameResolved = resolvedType.FullName, patchResults, warnings = warnings.Count > 0 ? warnings : null } ); } private static object HandleModify(JObject @params) { if (!TryResolveTarget(@params["target"], out var target, out var targetPath, out var targetGuid, out var err)) { return err; } var patchesToken = @params["patches"]; if (patchesToken == null || patchesToken.Type == JTokenType.Null) { return new ErrorResponse(CodeInvalidParams, new { message = "'patches' is required.", targetPath, targetGuid }); } if (patchesToken is not JArray patches) { return new ErrorResponse(CodeInvalidParams, new { message = "'patches' must be an array.", targetPath, targetGuid }); } var (results, warnings) = ApplyPatches(target, patches); return new SuccessResponse( "Serialized properties patched.", new { targetGuid, targetPath, targetTypeName = target.GetType().FullName, results, warnings = warnings.Count > 0 ? warnings : null } ); } private static (List results, List warnings) ApplyPatches(UnityEngine.Object target, JArray patches) { var warnings = new List(); var results = new List(patches.Count); bool anyChanged = false; var so = new SerializedObject(target); so.Update(); for (int i = 0; i < patches.Count; i++) { if (patches[i] is not JObject patchObj) { results.Add(new { propertyPath = "", op = "", ok = false, message = $"Patch at index {i} must be an object." }); continue; } string propertyPath = patchObj["propertyPath"]?.ToString() ?? patchObj["property_path"]?.ToString() ?? patchObj["path"]?.ToString(); string op = (patchObj["op"]?.ToString() ?? "set").Trim(); if (string.IsNullOrWhiteSpace(propertyPath)) { results.Add(new { propertyPath = propertyPath ?? "", op, ok = false, message = "Missing required field: propertyPath" }); continue; } if (string.IsNullOrWhiteSpace(op)) { op = "set"; } var patchResult = ApplyPatch(so, propertyPath, op, patchObj, out bool changed); anyChanged |= changed; results.Add(patchResult); // Array resize should be applied immediately so later paths resolve. if (string.Equals(op, "array_resize", StringComparison.OrdinalIgnoreCase) && changed) { so.ApplyModifiedProperties(); so.Update(); } } if (anyChanged) { so.ApplyModifiedProperties(); EditorUtility.SetDirty(target); AssetDatabase.SaveAssets(); } return (results, warnings); } private static object ApplyPatch(SerializedObject so, string propertyPath, string op, JObject patchObj, out bool changed) { changed = false; try { string normalizedOp = op.Trim().ToLowerInvariant(); switch (normalizedOp) { case "array_resize": return ApplyArrayResize(so, propertyPath, patchObj, out changed); case "set": default: return ApplySet(so, propertyPath, patchObj, out changed); } } catch (Exception ex) { return new { propertyPath, op, ok = false, message = ex.Message }; } } private static object ApplyArrayResize(SerializedObject so, string propertyPath, JObject patchObj, out bool changed) { changed = false; if (!TryGetInt(patchObj["value"], out int newSize)) { return new { propertyPath, op = "array_resize", ok = false, message = "array_resize requires integer 'value'." }; } newSize = Math.Max(0, newSize); // Unity supports resizing either: // - the array/list property itself (prop.isArray -> prop.arraySize) // - the synthetic leaf property ".Array.size" (prop.intValue) // // Different Unity versions/serialization edge cases can fail to resolve the synthetic leaf via FindProperty // (or can return different property types), so we keep a "best-effort" fallback: // - Prefer acting on the requested path if it resolves. // - If the requested path doesn't resolve, try to resolve the *array property* and set arraySize directly. SerializedProperty prop = so.FindProperty(propertyPath); SerializedProperty arrayProp = null; if (propertyPath.EndsWith(".Array.size", StringComparison.Ordinal)) { // Caller explicitly targeted the synthetic leaf. Resolve the parent array property as a fallback // (Unity sometimes fails to resolve the synthetic leaf in certain serialization contexts). var arrayPath = propertyPath.Substring(0, propertyPath.Length - ".Array.size".Length); arrayProp = so.FindProperty(arrayPath); } else { // Caller targeted either the array property itself (e.g., "items") or some other property. // If it's already an array, we can resize it directly. Otherwise, we attempt to resolve // a synthetic ".Array.size" leaf as a convenience, which some clients may pass. arrayProp = prop != null && prop.isArray ? prop : so.FindProperty(propertyPath + ".Array.size"); } if (prop == null) { // If we failed to find the direct property but we *can* find the array property, use that. if (arrayProp != null && arrayProp.isArray) { if (arrayProp.arraySize != newSize) { arrayProp.arraySize = newSize; changed = true; } return new { propertyPath, op = "array_resize", ok = true, resolvedPropertyType = "Array", message = $"Set array size to {newSize}." }; } return new { propertyPath, op = "array_resize", ok = false, message = $"Property not found: {propertyPath}" }; } // Unity may represent ".Array.size" as either Integer or ArraySize depending on version. if ((prop.propertyType == SerializedPropertyType.Integer || prop.propertyType == SerializedPropertyType.ArraySize) && propertyPath.EndsWith(".Array.size", StringComparison.Ordinal)) { // We successfully resolved the synthetic leaf; write the size through its intValue. if (prop.intValue != newSize) { prop.intValue = newSize; changed = true; } return new { propertyPath, op = "array_resize", ok = true, resolvedPropertyType = prop.propertyType.ToString(), message = $"Set array size to {newSize}." }; } if (prop.isArray) { // We resolved the array property itself; write through arraySize. if (prop.arraySize != newSize) { prop.arraySize = newSize; changed = true; } return new { propertyPath, op = "array_resize", ok = true, resolvedPropertyType = "Array", message = $"Set array size to {newSize}." }; } return new { propertyPath, op = "array_resize", ok = false, resolvedPropertyType = prop.propertyType.ToString(), message = $"Property is not an array or array-size field: {propertyPath}" }; } private static object ApplySet(SerializedObject so, string propertyPath, JObject patchObj, out bool changed) { changed = false; var prop = so.FindProperty(propertyPath); if (prop == null) { return new { propertyPath, op = "set", ok = false, message = $"Property not found: {propertyPath}" }; } if (prop.propertyType == SerializedPropertyType.ObjectReference) { var refObj = patchObj["ref"] as JObject; UnityEngine.Object newRef = null; string refGuid = refObj?["guid"]?.ToString(); string refPath = refObj?["path"]?.ToString(); if (refObj == null && patchObj["value"]?.Type == JTokenType.Null) { newRef = null; } else if (!string.IsNullOrEmpty(refGuid) || !string.IsNullOrEmpty(refPath)) { string resolvedPath = !string.IsNullOrEmpty(refGuid) ? AssetDatabase.GUIDToAssetPath(refGuid) : AssetPathUtility.SanitizeAssetPath(refPath); if (!string.IsNullOrEmpty(resolvedPath)) { newRef = AssetDatabase.LoadAssetAtPath(resolvedPath); } } if (prop.objectReferenceValue != newRef) { prop.objectReferenceValue = newRef; changed = true; } return new { propertyPath, op = "set", ok = true, resolvedPropertyType = prop.propertyType.ToString(), message = newRef == null ? "Cleared reference." : "Set reference." }; } var valueToken = patchObj["value"]; if (valueToken == null) { return new { propertyPath, op = "set", ok = false, resolvedPropertyType = prop.propertyType.ToString(), message = "Missing required field: value" }; } bool ok = TrySetValue(prop, valueToken, out string message); changed = ok; return new { propertyPath, op = "set", ok, resolvedPropertyType = prop.propertyType.ToString(), message }; } private static bool TrySetValue(SerializedProperty prop, JToken valueToken, out string message) { message = null; try { // Supported Types: Integer, Boolean, Float, String, Enum, Vector2, Vector3, Vector4, Color switch (prop.propertyType) { case SerializedPropertyType.Integer: if (!TryGetInt(valueToken, out var intVal)) { message = "Expected integer value."; return false; } prop.intValue = intVal; message = "Set int."; return true; case SerializedPropertyType.Boolean: if (!TryGetBool(valueToken, out var boolVal)) { message = "Expected boolean value."; return false; } prop.boolValue = boolVal; message = "Set bool."; return true; case SerializedPropertyType.Float: if (!TryGetFloat(valueToken, out var floatVal)) { message = "Expected float value."; return false; } prop.floatValue = floatVal; message = "Set float."; return true; case SerializedPropertyType.String: prop.stringValue = valueToken.Type == JTokenType.Null ? null : valueToken.ToString(); message = "Set string."; return true; case SerializedPropertyType.Enum: return TrySetEnum(prop, valueToken, out message); case SerializedPropertyType.Vector2: if (!TryGetVector2(valueToken, out var v2)) { message = "Expected Vector2 (array or object)."; return false; } prop.vector2Value = v2; message = "Set Vector2."; return true; case SerializedPropertyType.Vector3: if (!TryGetVector3(valueToken, out var v3)) { message = "Expected Vector3 (array or object)."; return false; } prop.vector3Value = v3; message = "Set Vector3."; return true; case SerializedPropertyType.Vector4: if (!TryGetVector4(valueToken, out var v4)) { message = "Expected Vector4 (array or object)."; return false; } prop.vector4Value = v4; message = "Set Vector4."; return true; case SerializedPropertyType.Color: if (!TryGetColor(valueToken, out var col)) { message = "Expected Color (array or object)."; return false; } prop.colorValue = col; message = "Set Color."; return true; default: message = $"Unsupported SerializedPropertyType: {prop.propertyType}"; return false; } } catch (Exception ex) { message = ex.Message; return false; } } private static bool TrySetEnum(SerializedProperty prop, JToken valueToken, out string message) { message = null; var names = prop.enumNames; if (names == null || names.Length == 0) { message = "Enum has no names."; return false; } if (valueToken.Type == JTokenType.Integer) { int idx = valueToken.Value(); if (idx < 0 || idx >= names.Length) { message = $"Enum index out of range: {idx}"; return false; } prop.enumValueIndex = idx; message = "Set enum."; return true; } string s = valueToken.ToString(); for (int i = 0; i < names.Length; i++) { if (string.Equals(names[i], s, StringComparison.OrdinalIgnoreCase)) { prop.enumValueIndex = i; message = "Set enum."; return true; } } message = $"Unknown enum name '{s}'."; return false; } private static bool TryResolveTarget(JToken targetToken, out UnityEngine.Object target, out string targetPath, out string targetGuid, out object error) { target = null; targetPath = null; targetGuid = null; error = null; if (targetToken is not JObject targetObj) { error = new ErrorResponse(CodeInvalidParams, new { message = "'target' must be an object with {guid|path}." }); return false; } string guid = targetObj["guid"]?.ToString(); string path = targetObj["path"]?.ToString(); if (string.IsNullOrWhiteSpace(guid) && string.IsNullOrWhiteSpace(path)) { error = new ErrorResponse(CodeInvalidParams, new { message = "'target' must include 'guid' or 'path'." }); return false; } string resolvedPath = !string.IsNullOrWhiteSpace(guid) ? AssetDatabase.GUIDToAssetPath(guid) : AssetPathUtility.SanitizeAssetPath(path); if (string.IsNullOrWhiteSpace(resolvedPath)) { error = new ErrorResponse(CodeTargetNotFound, new { message = "Could not resolve target path.", guid, path }); return false; } var obj = AssetDatabase.LoadAssetAtPath(resolvedPath); if (obj == null) { error = new ErrorResponse(CodeTargetNotFound, new { message = "Target asset not found.", targetPath = resolvedPath, targetGuid = guid }); return false; } target = obj; targetPath = resolvedPath; targetGuid = string.IsNullOrWhiteSpace(guid) ? AssetDatabase.AssetPathToGUID(resolvedPath) : guid; return true; } private static void CoerceJsonStringArrayParameter(JObject @params, string paramName) { var token = @params?[paramName]; if (token != null && token.Type == JTokenType.String) { try { var parsed = JToken.Parse(token.ToString()); if (parsed is JArray arr) { @params[paramName] = arr; } } catch (Exception e) { Debug.LogWarning($"[MCP] Could not parse '{paramName}' JSON string: {e.Message}"); } } } private static bool EnsureFolderExists(string folderPath, out string error) { error = null; if (string.IsNullOrWhiteSpace(folderPath)) { error = "Folder path is empty."; return false; } // Expect normalized input here (Assets/... or Assets). string sanitized = SanitizeSlashes(folderPath); if (!sanitized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) && !string.Equals(sanitized, "Assets", StringComparison.OrdinalIgnoreCase)) { error = "Folder path must be under Assets/."; return false; } if (string.Equals(sanitized, "Assets", StringComparison.OrdinalIgnoreCase)) { return true; } sanitized = sanitized.TrimEnd('/'); if (AssetDatabase.IsValidFolder(sanitized)) { return true; } // Create recursively from Assets/ var parts = sanitized.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0 || !string.Equals(parts[0], "Assets", StringComparison.OrdinalIgnoreCase)) { error = "Folder path must start with Assets/"; return false; } string current = "Assets"; for (int i = 1; i < parts.Length; i++) { string next = current + "/" + parts[i]; if (!AssetDatabase.IsValidFolder(next)) { string guid = AssetDatabase.CreateFolder(current, parts[i]); if (string.IsNullOrEmpty(guid)) { error = $"Failed to create folder: {next}"; return false; } } current = next; } return AssetDatabase.IsValidFolder(sanitized); } private static string SanitizeSlashes(string path) { if (string.IsNullOrWhiteSpace(path)) { return path; } var s = path.Replace('\\', '/'); while (s.IndexOf("//", StringComparison.Ordinal) >= 0) { s = s.Replace("//", "/", StringComparison.Ordinal); } return s; } private static bool TryNormalizeFolderPath(string folderPath, out string normalized, out string error) { normalized = null; error = null; if (string.IsNullOrWhiteSpace(folderPath)) { error = "Folder path is empty."; return false; } var s = SanitizeSlashes(folderPath.Trim()); // Reject obvious non-project/invalid roots. We only support Assets/ (and relative paths that will be rooted under Assets/). if (s.StartsWith("/", StringComparison.Ordinal) || s.StartsWith("file:", StringComparison.OrdinalIgnoreCase) || Regex.IsMatch(s, @"^[a-zA-Z]:")) { error = "Folder path must be a project-relative path under Assets/."; return false; } if (s.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase) || s.StartsWith("ProjectSettings/", StringComparison.OrdinalIgnoreCase) || s.StartsWith("Library/", StringComparison.OrdinalIgnoreCase)) { error = "Folder path must be under Assets/."; return false; } if (string.Equals(s, "Assets", StringComparison.OrdinalIgnoreCase)) { normalized = "Assets"; return true; } if (s.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { normalized = s.TrimEnd('/'); return true; } // Allow relative paths like "Temp/MyFolder" and root them under Assets/. normalized = ("Assets/" + s.TrimStart('/')).TrimEnd('/'); return true; } private static bool TryGetInt(JToken token, out int value) { value = default; if (token == null || token.Type == JTokenType.Null) return false; try { if (token.Type == JTokenType.Integer) { value = token.Value(); return true; } if (token.Type == JTokenType.Float) { value = Convert.ToInt32(token.Value()); return true; } var s = token.ToString().Trim(); return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); } catch { return false; } } private static bool TryGetFloat(JToken token, out float value) { value = default; if (token == null || token.Type == JTokenType.Null) return false; try { if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) { value = token.Value(); return true; } var s = token.ToString().Trim(); return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out value); } catch { return false; } } private static bool TryGetBool(JToken token, out bool value) { value = default; if (token == null || token.Type == JTokenType.Null) return false; try { if (token.Type == JTokenType.Boolean) { value = token.Value(); return true; } var s = token.ToString().Trim(); return bool.TryParse(s, out value); } catch { return false; } } // --- Vector/Color Parsing Helpers --- private static bool TryGetVector2(JToken token, out Vector2 value) { value = default; if (token == null || token.Type == JTokenType.Null) return false; // Handle [x, y] if (token is JArray arr && arr.Count >= 2) { if (TryGetFloat(arr[0], out float x) && TryGetFloat(arr[1], out float y)) { value = new Vector2(x, y); return true; } } // Handle { "x": ..., "y": ... } if (token is JObject obj) { if (TryGetFloat(obj["x"], out float x) && TryGetFloat(obj["y"], out float y)) { value = new Vector2(x, y); return true; } } return false; } private static bool TryGetVector3(JToken token, out Vector3 value) { value = default; if (token == null || token.Type == JTokenType.Null) return false; // Handle [x, y, z] if (token is JArray arr && arr.Count >= 3) { if (TryGetFloat(arr[0], out float x) && TryGetFloat(arr[1], out float y) && TryGetFloat(arr[2], out float z)) { value = new Vector3(x, y, z); return true; } } // Handle { "x": ..., "y": ..., "z": ... } if (token is JObject obj) { if (TryGetFloat(obj["x"], out float x) && TryGetFloat(obj["y"], out float y) && TryGetFloat(obj["z"], out float z)) { value = new Vector3(x, y, z); return true; } } return false; } private static bool TryGetVector4(JToken token, out Vector4 value) { value = default; if (token == null || token.Type == JTokenType.Null) return false; // Handle [x, y, z, w] if (token is JArray arr && arr.Count >= 4) { if (TryGetFloat(arr[0], out float x) && TryGetFloat(arr[1], out float y) && TryGetFloat(arr[2], out float z) && TryGetFloat(arr[3], out float w)) { value = new Vector4(x, y, z, w); return true; } } // Handle { "x": ..., "y": ..., "z": ..., "w": ... } if (token is JObject obj) { if (TryGetFloat(obj["x"], out float x) && TryGetFloat(obj["y"], out float y) && TryGetFloat(obj["z"], out float z) && TryGetFloat(obj["w"], out float w)) { value = new Vector4(x, y, z, w); return true; } } return false; } private static bool TryGetColor(JToken token, out Color value) { value = default; if (token == null || token.Type == JTokenType.Null) return false; // Handle [r, g, b, a] if (token is JArray arr && arr.Count >= 3) { float r = 0, g = 0, b = 0, a = 1; bool ok = TryGetFloat(arr[0], out r) && TryGetFloat(arr[1], out g) && TryGetFloat(arr[2], out b); if (arr.Count > 3) TryGetFloat(arr[3], out a); if (ok) { value = new Color(r, g, b, a); return true; } } // Handle { "r": ..., "g": ..., "b": ..., "a": ... } if (token is JObject obj) { if (TryGetFloat(obj["r"], out float r) && TryGetFloat(obj["g"], out float g) && TryGetFloat(obj["b"], out float b)) { // Alpha is optional, defaults to 1.0 float a = 1.0f; TryGetFloat(obj["a"], out a); value = new Color(r, g, b, a); return true; } } return false; } private static string NormalizeAction(string raw) { var s = raw.Trim(); s = s.Replace("-", "").Replace("_", ""); return s.ToLowerInvariant(); } private static bool IsCreateAction(string normalized) { return normalized == "create" || normalized == "createso"; } /// /// Resolves a type by name. Delegates to UnityTypeResolver.ResolveAny(). /// private static Type ResolveType(string typeName) { return Helpers.UnityTypeResolver.ResolveAny(typeName); } } }