Update GameObject for two new features (#427)

Tackle the requests in Issue#267, tested and no bugs found.

(1)LLM can duplicate an gameobject based on request
(2)LLM can move a gameobject relative to another gameobject
main
Shutong Wu 2025-12-04 13:11:28 -05:00 committed by GitHub
parent aa47838bcd
commit a69ce19a7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 245 additions and 4 deletions

View File

@ -189,6 +189,10 @@ namespace MCPForUnity.Editor.Tools
return RemoveComponentFromTarget(@params, targetToken, searchMethod); return RemoveComponentFromTarget(@params, targetToken, searchMethod);
case "set_component_property": case "set_component_property":
return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); return SetComponentPropertyOnTarget(@params, targetToken, searchMethod);
case "duplicate":
return DuplicateGameObject(@params, targetToken, searchMethod);
case "move_relative":
return MoveRelativeToObject(@params, targetToken, searchMethod);
default: default:
return new ErrorResponse($"Unknown action: '{action}'."); return new ErrorResponse($"Unknown action: '{action}'.");
@ -898,6 +902,219 @@ namespace MCPForUnity.Editor.Tools
} }
/// <summary>
/// Duplicates a GameObject with all its properties, components, and children.
/// </summary>
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)
}
);
}
/// <summary>
/// Moves a GameObject relative to another reference object.
/// Supports directional offsets (left, right, up, down, forward, back) and distance.
/// </summary>
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<float>() ?? 1f;
Vector3? customOffset = ParseVector3(@params["offset"] as JArray);
bool useWorldSpace = @params["world_space"]?.ToObject<bool>() ?? 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)
}
);
}
/// <summary>
/// Converts a direction string to a Vector3.
/// </summary>
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) private static object DeleteGameObject(JToken targetToken, string searchMethod)
{ {
// Find potentially multiple objects if name/tag search is used without find_all=false implicitly // 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. // Removed GetGameObjectData, GetComponentData, and related private helpers/caching/serializer setup.
// They are now in Helpers.GameObjectSerializer // They are now in Helpers.GameObjectSerializer
} }
} }

View File

@ -13,7 +13,7 @@ from transport.legacy.unity_connection import async_send_command_with_retry
) )
async def manage_gameobject( async def manage_gameobject(
ctx: Context, 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, target: Annotated[str,
"GameObject identifier by name or path for modify/delete/component actions"] | None = None, "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"], 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 # Controls whether serialization of private [SerializeField] fields is included
includeNonPublicSerialized: Annotated[bool | str, includeNonPublicSerialized: Annotated[bool | str,
"Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None, "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]: ) -> dict[str, Any]:
# Get active instance from session state # Get active instance from session state
# Removed session_state import # Removed session_state import
@ -113,12 +127,14 @@ async def manage_gameobject(
position = _coerce_vec(position, default=position) position = _coerce_vec(position, default=position)
rotation = _coerce_vec(rotation, default=rotation) rotation = _coerce_vec(rotation, default=rotation)
scale = _coerce_vec(scale, default=scale) scale = _coerce_vec(scale, default=scale)
offset = _coerce_vec(offset, default=offset)
save_as_prefab = _coerce_bool(save_as_prefab) save_as_prefab = _coerce_bool(save_as_prefab)
set_active = _coerce_bool(set_active) set_active = _coerce_bool(set_active)
find_all = _coerce_bool(find_all) find_all = _coerce_bool(find_all)
search_in_children = _coerce_bool(search_in_children) search_in_children = _coerce_bool(search_in_children)
search_inactive = _coerce_bool(search_inactive) search_inactive = _coerce_bool(search_inactive)
includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized) includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized)
world_space = _coerce_bool(world_space, default=True)
# Coerce 'component_properties' from JSON string to dict for client compatibility # Coerce 'component_properties' from JSON string to dict for client compatibility
if isinstance(component_properties, str): if isinstance(component_properties, str):
@ -181,7 +197,15 @@ async def manage_gameobject(
"searchInChildren": search_in_children, "searchInChildren": search_in_children,
"searchInactive": search_inactive, "searchInactive": search_inactive,
"componentName": component_name, "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} 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)} return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e: 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)}"}