Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions MCPForUnity/Editor/Tools/Animation/AnimatorControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ public static object Play(JObject @params)
if (go == null)
return new { success = false, message = "Target GameObject not found" };

var animator = go.GetComponent<Animator>();
var animator = AnimatorResolver.Find(go, out var animatorCandidates);
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
return AnimatorResolver.NotResolvedError(go, animatorCandidates);

string stateName = @params["stateName"]?.ToString();
if (string.IsNullOrEmpty(stateName))
Expand All @@ -28,7 +28,7 @@ public static object Play(JObject @params)
Undo.RecordObject(animator, "Play Animation State");
animator.Play(stateName, layer);

return new { success = true, message = $"Playing state '{stateName}' on '{go.name}'" };
return new { success = true, message = $"Playing state '{stateName}' on {AnimatorResolver.Describe(go, animator)}" };
}

public static object Crossfade(JObject @params)
Expand All @@ -37,9 +37,9 @@ public static object Crossfade(JObject @params)
if (go == null)
return new { success = false, message = "Target GameObject not found" };

var animator = go.GetComponent<Animator>();
var animator = AnimatorResolver.Find(go, out var animatorCandidates);
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
return AnimatorResolver.NotResolvedError(go, animatorCandidates);

string stateName = @params["stateName"]?.ToString();
if (string.IsNullOrEmpty(stateName))
Expand All @@ -51,7 +51,7 @@ public static object Crossfade(JObject @params)
Undo.RecordObject(animator, "Crossfade Animation State");
animator.CrossFade(stateName, duration, layer);

return new { success = true, message = $"Crossfading to '{stateName}' over {duration}s on '{go.name}'" };
return new { success = true, message = $"Crossfading to '{stateName}' over {duration}s on {AnimatorResolver.Describe(go, animator)}" };
}

public static object SetParameter(JObject @params)
Expand All @@ -60,9 +60,9 @@ public static object SetParameter(JObject @params)
if (go == null)
return new { success = false, message = "Target GameObject not found" };

var animator = go.GetComponent<Animator>();
var animator = AnimatorResolver.Find(go, out var animatorCandidates);
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
return AnimatorResolver.NotResolvedError(go, animatorCandidates);

string paramName = @params["parameterName"]?.ToString();
if (string.IsNullOrEmpty(paramName))
Expand Down Expand Up @@ -103,23 +103,23 @@ public static object SetParameter(JObject @params)
case "float":
float fVal = valueToken?.ToObject<float>() ?? 0f;
animator.SetFloat(paramName, fVal);
return new { success = true, message = $"Set float '{paramName}' = {fVal}" };
return new { success = true, message = $"Set float '{paramName}' = {fVal}" + AnimatorResolver.ResolvedSuffix(go, animator) };

case "int":
case "integer":
int iVal = valueToken?.ToObject<int>() ?? 0;
animator.SetInteger(paramName, iVal);
return new { success = true, message = $"Set int '{paramName}' = {iVal}" };
return new { success = true, message = $"Set int '{paramName}' = {iVal}" + AnimatorResolver.ResolvedSuffix(go, animator) };

case "bool":
case "boolean":
bool bVal = valueToken?.ToObject<bool>() ?? false;
animator.SetBool(paramName, bVal);
return new { success = true, message = $"Set bool '{paramName}' = {bVal}" };
return new { success = true, message = $"Set bool '{paramName}' = {bVal}" + AnimatorResolver.ResolvedSuffix(go, animator) };

case "trigger":
animator.SetTrigger(paramName);
return new { success = true, message = $"Set trigger '{paramName}'" };
return new { success = true, message = $"Set trigger '{paramName}'" + AnimatorResolver.ResolvedSuffix(go, animator) };

default:
return new { success = false, message = $"Unknown parameter type: {paramType}. Valid: float, int, bool, trigger" };
Expand All @@ -130,7 +130,7 @@ public static object SetParameter(JObject @params)
// Edit mode: modify the AnimatorController asset's default parameter values
var controller = animator.runtimeAnimatorController as AnimatorController;
if (controller == null)
return new { success = false, message = $"No AnimatorController assigned to Animator on '{go.name}'. Cannot set parameter defaults in Edit mode." };
return new { success = false, message = $"No AnimatorController assigned to the Animator on {AnimatorResolver.Describe(go, animator)}. Cannot set parameter defaults in Edit mode." };

var allParams = controller.parameters;
int paramIndex = -1;
Expand All @@ -156,7 +156,7 @@ public static object SetParameter(JObject @params)
controller.parameters = allParams;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new { success = true, message = $"Set float '{paramName}' = {fVal} (default value, Edit mode)" };
return new { success = true, message = $"Set float '{paramName}' = {fVal} (default value, Edit mode)" + AnimatorResolver.ResolvedSuffix(go, animator) };

case "int":
case "integer":
Expand All @@ -165,7 +165,7 @@ public static object SetParameter(JObject @params)
controller.parameters = allParams;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new { success = true, message = $"Set int '{paramName}' = {iVal} (default value, Edit mode)" };
return new { success = true, message = $"Set int '{paramName}' = {iVal} (default value, Edit mode)" + AnimatorResolver.ResolvedSuffix(go, animator) };

case "bool":
case "boolean":
Expand All @@ -174,10 +174,10 @@ public static object SetParameter(JObject @params)
controller.parameters = allParams;
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
return new { success = true, message = $"Set bool '{paramName}' = {bVal} (default value, Edit mode)" };
return new { success = true, message = $"Set bool '{paramName}' = {bVal} (default value, Edit mode)" + AnimatorResolver.ResolvedSuffix(go, animator) };

case "trigger":
return new { success = true, message = $"Trigger '{paramName}' noted (triggers are runtime-only, no default to set)" };
return new { success = true, message = $"Trigger '{paramName}' noted (triggers are runtime-only, no default to set)" + AnimatorResolver.ResolvedSuffix(go, animator) };

default:
return new { success = false, message = $"Unknown parameter type: {paramType}. Valid: float, int, bool, trigger" };
Expand All @@ -191,16 +191,16 @@ public static object SetSpeed(JObject @params)
if (go == null)
return new { success = false, message = "Target GameObject not found" };

var animator = go.GetComponent<Animator>();
var animator = AnimatorResolver.Find(go, out var animatorCandidates);
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
return AnimatorResolver.NotResolvedError(go, animatorCandidates);

float speed = @params["speed"]?.ToObject<float>() ?? 1f;

Undo.RecordObject(animator, "Set Animator Speed");
animator.speed = speed;

return new { success = true, message = $"Set animator speed to {speed} on '{go.name}'" };
return new { success = true, message = $"Set animator speed to {speed} on {AnimatorResolver.Describe(go, animator)}" };
}

public static object SetEnabled(JObject @params)
Expand All @@ -209,16 +209,16 @@ public static object SetEnabled(JObject @params)
if (go == null)
return new { success = false, message = "Target GameObject not found" };

var animator = go.GetComponent<Animator>();
var animator = AnimatorResolver.Find(go, out var animatorCandidates);
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
return AnimatorResolver.NotResolvedError(go, animatorCandidates);

bool enabled = @params["enabled"]?.ToObject<bool>() ?? true;

Undo.RecordObject(animator, "Set Animator Enabled");
animator.enabled = enabled;

return new { success = true, message = $"Animator {(enabled ? "enabled" : "disabled")} on '{go.name}'" };
return new { success = true, message = $"Animator {(enabled ? "enabled" : "disabled")} on {AnimatorResolver.Describe(go, animator)}" };
}
}
}
9 changes: 5 additions & 4 deletions MCPForUnity/Editor/Tools/Animation/AnimatorRead.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ public static object GetInfo(JObject @params)
if (go == null)
return new { success = false, message = "Target GameObject not found" };

var animator = go.GetComponent<Animator>();
var animator = AnimatorResolver.Find(go, out var animatorCandidates);
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
return AnimatorResolver.NotResolvedError(go, animatorCandidates);

var parameters = new List<object>();
for (int i = 0; i < animator.parameterCount; i++)
Expand Down Expand Up @@ -73,6 +73,7 @@ public static object GetInfo(JObject @params)
data = new
{
gameObject = go.name,
animatorGameObject = animator.gameObject.name,
enabled = animator.enabled,
speed = animator.speed,
hasController = animator.runtimeAnimatorController != null,
Expand All @@ -95,9 +96,9 @@ public static object GetParameter(JObject @params)
if (go == null)
return new { success = false, message = "Target GameObject not found" };

var animator = go.GetComponent<Animator>();
var animator = AnimatorResolver.Find(go, out var animatorCandidates);
if (animator == null)
return new { success = false, message = $"No Animator component on '{go.name}'" };
return AnimatorResolver.NotResolvedError(go, animatorCandidates);

string paramName = @params["parameterName"]?.ToString();
if (string.IsNullOrEmpty(paramName))
Expand Down
82 changes: 82 additions & 0 deletions MCPForUnity/Editor/Tools/Animation/AnimatorResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Linq;
using UnityEngine;

namespace MCPForUnity.Editor.Tools.Animation
{
internal static class AnimatorResolver
{
/// <summary>
/// Resolves the Animator that read and control operations should act on: the one on
/// <paramref name="go"/> itself, or the single Animator among its descendants.
/// </summary>
/// <remarks>
/// Imported models keep their Animator on the model root, which is normally a child of
/// the GameObject a caller names, so an exact-match lookup fails on the most common rig
/// setup. Inactive descendants are included because a disabled rig is still readable.
/// Operations that ADD an Animator must not use this - they need the exact target.
/// </remarks>
/// <param name="candidates">
/// The descendant Animators found when the target carried none. Unity's descendant search
/// is depth-first, so with several rigs under one wrapper it returns the first branch
/// however deep, which is not the nearest rig and not what a caller would predict. More
/// than one candidate therefore resolves to null and is reported, never guessed.
/// </param>
/// <returns>The resolved Animator, or null when there is none or the choice is ambiguous.</returns>
public static Animator Find(GameObject go, out Animator[] candidates)
{
candidates = System.Array.Empty<Animator>();
if (go == null)
return null;

var own = go.GetComponent<Animator>();
if (own != null)
return own;

// go carries none, so every hit here is a descendant.
candidates = go.GetComponentsInChildren<Animator>(true);
return candidates.Length == 1 ? candidates[0] : null;
}

/// <summary>
/// The error for a target whose Animator could not be resolved - missing, or ambiguous
/// because several descendants carry one.
/// </summary>
public static object NotResolvedError(GameObject go, Animator[] candidates)
{
if (candidates != null && candidates.Length > 1)
{
string names = string.Join(", ", candidates.Select(a => $"'{a.gameObject.name}'"));
return new
{
success = false,
message = $"'{go.name}' has no Animator and {candidates.Length} of its children do " +
$"({names}). Target one of them directly."
};
}

return new { success = false, message = $"No Animator component on '{go.name}' or its children" };
}

/// <summary>
/// Names the object a response should report as changed. When resolution retargeted to a
/// descendant, the caller is told so - otherwise the response claims the wrapper changed.
/// </summary>
/// <summary>
/// A suffix disclosing that resolution retargeted to a descendant; empty when the target
/// carried the Animator itself, so responses that already read correctly are untouched.
/// </summary>
public static string ResolvedSuffix(GameObject target, Animator resolved)
{
return resolved.gameObject == target
? string.Empty
: $" (on '{resolved.gameObject.name}', resolved from '{target.name}')";
}

public static string Describe(GameObject target, Animator resolved)
{
return resolved.gameObject == target
? $"'{target.name}'"
: $"'{resolved.gameObject.name}' (resolved from '{target.name}')";
}
}
}
11 changes: 11 additions & 0 deletions MCPForUnity/Editor/Tools/Animation/AnimatorResolver.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Server/src/cli/CLI_USAGE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,7 @@ unity-mcp audio volume "MusicPlayer" 0.5
### Animation Commands

```bash
# Control Animator (target must have Animator component)
# Control Animator (target or one of its children must have an Animator component)
unity-mcp animation play "Character" "Walk"
unity-mcp animation set-parameter "Character" "Speed" 1.5 --type float
unity-mcp animation set-parameter "Character" "IsRunning" true --type bool
Expand Down
5 changes: 4 additions & 1 deletion Server/src/cli/commands/animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ def animator_play(target: str, state_name: str, layer: int, search_method: Optio
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Playing state '{state_name}' on {target}")
# Prefer the tool's own message: the Animator may live on a descendant of
# `target`, and only the tool knows which object actually played. The fallback
# names no object for the same reason - here the tool told us nothing.
print_success(result.get("message") or f"Playing state '{state_name}'")


@animator.command("crossfade")
Expand Down
26 changes: 26 additions & 0 deletions Server/tests/test_manage_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,32 @@ def test_animator_play_builds_correct_params(self, runner, mock_config, mock_suc
# stateName goes into properties (non-top-level key)
assert params["properties"]["stateName"] == "Walk"

def test_animator_play_reports_the_object_the_tool_resolved(self, runner, mock_config):
"""The Animator may live on a descendant of the named target, and only the tool
knows which object played. Echoing the CLI's own `target` back would contradict
the result it just printed."""
resolved = {
"success": True,
"message": "Playing state 'Walk' on 'Rig' (resolved from 'Wrapper')",
"data": {},
}
with patch("cli.commands.animation.get_config", return_value=mock_config):
with patch("cli.commands.animation.run_command", return_value=resolved):
result = runner.invoke(animation, ["animator", "play", "Wrapper", "Walk"])

assert "Rig" in result.output
assert "Playing state 'Walk' on Wrapper" not in result.output

def test_animator_play_fallback_names_no_object(self, runner, mock_config):
"""With no message the CLI cannot know whether the target or a descendant played,
so the fallback must not claim one."""
with patch("cli.commands.animation.get_config", return_value=mock_config):
with patch("cli.commands.animation.run_command", return_value={"success": True, "data": {}}):
result = runner.invoke(animation, ["animator", "play", "Wrapper", "Walk"])

assert "Playing state 'Walk'" in result.output
assert "on Wrapper" not in result.output

def test_animator_play_with_layer(self, runner, mock_config, mock_success):
with patch("cli.commands.animation.get_config", return_value=mock_config):
with patch("cli.commands.animation.run_command", return_value=mock_success) as mock_run:
Expand Down
Loading