diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs index bc876b9..8bfdfc0 100644 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -189,6 +189,10 @@ namespace MCPForUnity.Editor.Tools return RemoveComponentFromTarget(@params, targetToken, searchMethod); case "set_component_property": return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); + case "duplicate": + return DuplicateGameObject(@params, targetToken, searchMethod); + case "move_relative": + return MoveRelativeToObject(@params, targetToken, searchMethod); default: return new ErrorResponse($"Unknown action: '{action}'."); @@ -898,6 +902,219 @@ namespace MCPForUnity.Editor.Tools } + /// + /// Duplicates a GameObject with all its properties, components, and children. + /// + private static object DuplicateGameObject(JObject @params, JToken targetToken, string searchMethod) + { + GameObject sourceGo = FindObjectInternal(targetToken, searchMethod); + if (sourceGo == null) + { + return new ErrorResponse( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + // Optional parameters + string newName = @params["new_name"]?.ToString(); + Vector3? position = ParseVector3(@params["position"] as JArray); + Vector3? offset = ParseVector3(@params["offset"] as JArray); + JToken parentToken = @params["parent"]; + + // Duplicate the object + GameObject duplicatedGo = UnityEngine.Object.Instantiate(sourceGo); + Undo.RegisterCreatedObjectUndo(duplicatedGo, $"Duplicate {sourceGo.name}"); + + // Set name (default: SourceName_Copy or SourceName (1)) + if (!string.IsNullOrEmpty(newName)) + { + duplicatedGo.name = newName; + } + else + { + // Remove "(Clone)" suffix added by Instantiate and add "_Copy" + duplicatedGo.name = sourceGo.name.Replace("(Clone)", "").Trim() + "_Copy"; + } + + // Handle positioning + if (position.HasValue) + { + // Absolute position specified + duplicatedGo.transform.position = position.Value; + } + else if (offset.HasValue) + { + // Offset from original + duplicatedGo.transform.position = sourceGo.transform.position + offset.Value; + } + // else: keeps the same position as the original (default Instantiate behavior) + + // Handle parent + if (parentToken != null) + { + if (parentToken.Type == JTokenType.Null || + (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString()))) + { + // Explicit null parent - move to root + duplicatedGo.transform.SetParent(null); + } + else + { + GameObject newParent = FindObjectInternal(parentToken, "by_id_or_name_or_path"); + if (newParent != null) + { + duplicatedGo.transform.SetParent(newParent.transform, true); + } + else + { + Debug.LogWarning($"[ManageGameObject.Duplicate] Parent '{parentToken}' not found. Keeping original parent."); + } + } + } + else + { + // Default: same parent as source + duplicatedGo.transform.SetParent(sourceGo.transform.parent, true); + } + + // Mark scene dirty + EditorUtility.SetDirty(duplicatedGo); + EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); + + Selection.activeGameObject = duplicatedGo; + + return new SuccessResponse( + $"Duplicated '{sourceGo.name}' as '{duplicatedGo.name}'.", + new + { + originalName = sourceGo.name, + originalId = sourceGo.GetInstanceID(), + duplicatedObject = Helpers.GameObjectSerializer.GetGameObjectData(duplicatedGo) + } + ); + } + + /// + /// Moves a GameObject relative to another reference object. + /// Supports directional offsets (left, right, up, down, forward, back) and distance. + /// + private static object MoveRelativeToObject(JObject @params, JToken targetToken, string searchMethod) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return new ErrorResponse( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + // Get reference object (required for relative movement) + JToken referenceToken = @params["reference_object"]; + if (referenceToken == null) + { + return new ErrorResponse("'reference_object' parameter is required for 'move_relative' action."); + } + + GameObject referenceGo = FindObjectInternal(referenceToken, "by_id_or_name_or_path"); + if (referenceGo == null) + { + return new ErrorResponse($"Reference object '{referenceToken}' not found."); + } + + // Get movement parameters + string direction = @params["direction"]?.ToString()?.ToLower(); + float distance = @params["distance"]?.ToObject() ?? 1f; + Vector3? customOffset = ParseVector3(@params["offset"] as JArray); + bool useWorldSpace = @params["world_space"]?.ToObject() ?? true; + + // Record for undo + Undo.RecordObject(targetGo.transform, $"Move {targetGo.name} relative to {referenceGo.name}"); + + Vector3 newPosition; + + if (customOffset.HasValue) + { + // Custom offset vector provided + if (useWorldSpace) + { + newPosition = referenceGo.transform.position + customOffset.Value; + } + else + { + // Offset in reference object's local space + newPosition = referenceGo.transform.TransformPoint(customOffset.Value); + } + } + else if (!string.IsNullOrEmpty(direction)) + { + // Directional movement + Vector3 directionVector = GetDirectionVector(direction, referenceGo.transform, useWorldSpace); + newPosition = referenceGo.transform.position + directionVector * distance; + } + else + { + return new ErrorResponse("Either 'direction' or 'offset' parameter is required for 'move_relative' action."); + } + + targetGo.transform.position = newPosition; + + // Mark scene dirty + EditorUtility.SetDirty(targetGo); + EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); + + return new SuccessResponse( + $"Moved '{targetGo.name}' relative to '{referenceGo.name}'.", + new + { + movedObject = targetGo.name, + referenceObject = referenceGo.name, + newPosition = new[] { targetGo.transform.position.x, targetGo.transform.position.y, targetGo.transform.position.z }, + direction = direction, + distance = distance, + gameObject = Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + } + ); + } + + /// + /// Converts a direction string to a Vector3. + /// + private static Vector3 GetDirectionVector(string direction, Transform referenceTransform, bool useWorldSpace) + { + if (useWorldSpace) + { + // World space directions + switch (direction) + { + case "right": return Vector3.right; + case "left": return Vector3.left; + case "up": return Vector3.up; + case "down": return Vector3.down; + case "forward": case "front": return Vector3.forward; + case "back": case "backward": case "behind": return Vector3.back; + default: + Debug.LogWarning($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward."); + return Vector3.forward; + } + } + else + { + // Reference object's local space directions + switch (direction) + { + case "right": return referenceTransform.right; + case "left": return -referenceTransform.right; + case "up": return referenceTransform.up; + case "down": return -referenceTransform.up; + case "forward": case "front": return referenceTransform.forward; + case "back": case "backward": case "behind": return -referenceTransform.forward; + default: + Debug.LogWarning($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward."); + return referenceTransform.forward; + } + } + } + private static object DeleteGameObject(JToken targetToken, string searchMethod) { // Find potentially multiple objects if name/tag search is used without find_all=false implicitly @@ -2560,4 +2777,4 @@ namespace MCPForUnity.Editor.Tools // Removed GetGameObjectData, GetComponentData, and related private helpers/caching/serializer setup. // They are now in Helpers.GameObjectSerializer } -} +} \ No newline at end of file diff --git a/Server/src/services/tools/manage_gameobject.py b/Server/src/services/tools/manage_gameobject.py index 4d28c43..b9167b6 100644 --- a/Server/src/services/tools/manage_gameobject.py +++ b/Server/src/services/tools/manage_gameobject.py @@ -13,7 +13,7 @@ from transport.legacy.unity_connection import async_send_command_with_retry ) async def manage_gameobject( ctx: Context, - action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."], + action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component", "duplicate", "move_relative"], "Perform CRUD operations on GameObjects and components."], target: Annotated[str, "GameObject identifier by name or path for modify/delete/component actions"] | None = None, search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], @@ -66,6 +66,20 @@ async def manage_gameobject( # Controls whether serialization of private [SerializeField] fields is included includeNonPublicSerialized: Annotated[bool | str, "Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None, + # --- Parameters for 'duplicate' --- + new_name: Annotated[str, + "New name for the duplicated object (default: SourceName_Copy)"] | None = None, + offset: Annotated[list[float] | str, + "Offset from original/reference position - [x,y,z] or string '[x,y,z]'"] | None = None, + # --- Parameters for 'move_relative' --- + reference_object: Annotated[str, + "Reference object for relative movement (required for move_relative)"] | None = None, + direction: Annotated[Literal["left", "right", "up", "down", "forward", "back", "front", "backward", "behind"], + "Direction for relative movement (e.g., 'right', 'up', 'forward')"] | None = None, + distance: Annotated[float, + "Distance to move in the specified direction (default: 1.0)"] | None = None, + world_space: Annotated[bool | str, + "If True (default), use world space directions; if False, use reference object's local directions"] | None = None, ) -> dict[str, Any]: # Get active instance from session state # Removed session_state import @@ -113,12 +127,14 @@ async def manage_gameobject( position = _coerce_vec(position, default=position) rotation = _coerce_vec(rotation, default=rotation) scale = _coerce_vec(scale, default=scale) + offset = _coerce_vec(offset, default=offset) save_as_prefab = _coerce_bool(save_as_prefab) set_active = _coerce_bool(set_active) find_all = _coerce_bool(find_all) search_in_children = _coerce_bool(search_in_children) search_inactive = _coerce_bool(search_inactive) includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized) + world_space = _coerce_bool(world_space, default=True) # Coerce 'component_properties' from JSON string to dict for client compatibility if isinstance(component_properties, str): @@ -181,7 +197,15 @@ async def manage_gameobject( "searchInChildren": search_in_children, "searchInactive": search_inactive, "componentName": component_name, - "includeNonPublicSerialized": includeNonPublicSerialized + "includeNonPublicSerialized": includeNonPublicSerialized, + # Parameters for 'duplicate' + "new_name": new_name, + "offset": offset, + # Parameters for 'move_relative' + "reference_object": reference_object, + "direction": direction, + "distance": distance, + "world_space": world_space, } params = {k: v for k, v in params.items() if v is not None} @@ -217,4 +241,4 @@ async def manage_gameobject( return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: - return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} + return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file