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