Skip to content
1 change: 1 addition & 0 deletions .claude/skills/unity-mcp-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ uri="file:///full/path/to/file.cs"
| **Testing** | `run_tests`, `get_test_job` | Unity Test Framework |
| **Batch** | `batch_execute` | Parallel/bulk operations |
| **Camera** | `manage_camera` | Camera management (Unity Camera + Cinemachine). **Tier 1** (always available): create, target, lens, priority, list, screenshot. **Tier 2** (requires `com.unity.cinemachine`): brain, body/aim/noise pipeline, extensions, blending, force/release. 7 presets: follow, third_person, freelook, dolly, static, top_down, side_scroller. Resource: `mcpforunity://scene/cameras`. Use `ping` to check Cinemachine availability. See [tools-reference.md](references/tools-reference.md#camera-tools). |
| **Animation** | `manage_animation`, `manage_sprite` | Animator control, clips and controllers. **2D sprite sheets**: `manage_sprite(action="get_info")` returns a PNG or JPEG sheet as an image to count the grid from, then `full_setup` slices it and builds clips and a controller; clip names decide the states (idle, walk/run, attack-type triggers). Off by default over HTTP: `manage_tools(action="activate", group="animation")`. |
| **Graphics** | `manage_graphics` | Rendering and post-processing management. 33 actions across 5 groups: **Volume** (create/configure volumes and effects, URP/HDRP), **Bake** (lightmaps, light probes, reflection probes, Edit mode only), **Stats** (draw calls, batches, memory), **Pipeline** (quality levels, pipeline settings), **Features** (URP renderer features: add, remove, toggle, reorder). Resources: `mcpforunity://scene/volumes`, `mcpforunity://rendering/stats`, `mcpforunity://pipeline/renderer-features`. Use `ping` to check pipeline status. See [tools-reference.md](references/tools-reference.md#graphics-tools). |
| **Packages** | `manage_packages` | Install, remove, search, and manage Unity packages and scoped registries. Query actions: list installed, search registry, get info, ping, poll status. Mutating actions: add/remove packages, embed for editing, add/remove scoped registries, force resolve. Validates identifiers, warns on git URLs, checks dependents before removal (`force=true` to override). See [tools-reference.md](references/tools-reference.md#package-tools). |
| **ProBuilder** | `manage_probuilder` | 3D modeling, mesh editing, complex geometry. **When `com.unity.probuilder` is installed, prefer ProBuilder shapes over primitive GameObjects** for editable geometry, multi-material faces, or complex shapes. Supports 12 shape types, face/edge/vertex editing, smoothing, and per-face materials. See [ProBuilder Guide](references/probuilder-guide.md). |
Expand Down
48 changes: 40 additions & 8 deletions MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ internal static (string path, int stateCount) BuildController(
return default;
}
var rootSM = controller.layers[0].stateMachine;
// Every transition below gets duration 0: sprite keys are object references, which
// cannot blend, so a blend time would only delay the visible sprite change.

// ── Parameters ──────────────────────────────────────────────────

Expand All @@ -147,14 +149,20 @@ internal static (string path, int stateCount) BuildController(

// ── Idle state ────────────────────────────────────────────────────

var idlePair = entries.FirstOrDefault(e => e.entry.Category == SpriteAnimCategory.Idle);
var idlePairs = entries.Where(e => e.entry.Category == SpriteAnimCategory.Idle).ToList();
AnimatorState idleState = null;
if (idlePair.clip != null)
if (idlePairs.Count > 0)
{
idleState = rootSM.AddState("Idle");
idleState.motion = idlePair.clip;
idleState.motion = idlePairs[0].clip;
rootSM.defaultState = idleState;
}
// There is one Idle state, so a second idle clip is left out of the controller.
foreach (var extra in idlePairs.Skip(1))
diagnostics.AddWarning("IDLE_CLIP_UNUSED",
$"Clip '{extra.entry.ClipName}' is also an idle clip, and the one Idle state plays '{idlePairs[0].entry.ClipName}', so '{extra.entry.ClipName}' got no state.",
"Rename it to include an action word such as attack, jump or hurt, and neither idle nor stand, then rebuild with overwrite=true.",
"Put it in its own controller.");

// ── Locomotion ────────────────────────────────────────────────────

Expand All @@ -172,9 +180,11 @@ internal static (string path, int stateCount) BuildController(
var t1 = idleState.AddTransition(locoState);
t1.AddCondition(AnimatorConditionMode.Greater, 0.1f, "Speed");
t1.hasExitTime = false;
t1.duration = 0f;
var t2 = locoState.AddTransition(idleState);
t2.AddCondition(AnimatorConditionMode.Less, 0.1f, "Speed");
t2.hasExitTime = false;
t2.duration = 0f;
}
}
else
Expand All @@ -198,9 +208,11 @@ internal static (string path, int stateCount) BuildController(
var t1 = idleState.AddTransition(blendState);
t1.AddCondition(AnimatorConditionMode.Greater, 0.1f, "Speed");
t1.hasExitTime = false;
t1.duration = 0f;
var t2 = blendState.AddTransition(idleState);
t2.AddCondition(AnimatorConditionMode.Less, 0.1f, "Speed");
t2.hasExitTime = false;
t2.duration = 0f;
}
}
}
Expand All @@ -212,31 +224,47 @@ internal static (string path, int stateCount) BuildController(
e.entry.Category == SpriteAnimCategory.Jump ||
e.entry.Category == SpriteAnimCategory.Object).ToList();

// Trigger -> the clip whose state it enters. Two Any State transitions on one trigger
// always resolve to the same one, so the second could never fire and is not built;
// its clip keeps a state, with its exit, for a script to play.
var triggerOwners = new Dictionary<string, string>();
foreach (var pair in triggerPairs)
{
var state = rootSM.AddState(pair.entry.ClipName);
state.motion = pair.clip;

string trigger = pair.entry.TriggerName ?? pair.entry.ClipName;

foreach (var existingState in rootSM.states.Select(s => s.state))
if (triggerOwners.TryGetValue(trigger, out string owner))
{
if (existingState == state) continue;
var tr = existingState.AddTransition(state);
diagnostics.AddWarning("TRIGGER_SHARED",
$"Clips '{owner}' and '{pair.entry.ClipName}' share the trigger '{trigger}', which plays '{owner}': no transition leads to '{pair.entry.ClipName}', so it plays only from a script.",
"Give each clip its own action word (attack, slash and punch are three different triggers), then rebuild with overwrite=true.");
}
else
{
triggerOwners.Add(trigger, pair.entry.ClipName);
var tr = rootSM.AddAnyStateTransition(state);
tr.AddCondition(AnimatorConditionMode.If, 0, trigger);
tr.hasExitTime = false;
tr.duration = 0f;
// On, a repeated trigger restarts the clip. Off, Unity would leave that trigger
// set, and it would replay the state as soon as the Animator left it.
tr.canTransitionToSelf = true;
}

// A one-shot state hands control back to idle, else locomotion. With
// neither, the default is another one-shot, and exiting into it would
// just chain one stuck state into the next.
// just chain one stuck state into the next. A death gets no exit and holds
// its last frame; a trigger the game fires still leaves it, from Any State.
var exitTarget = idleState ?? locomotionState;
if (exitTarget != null && !pair.entry.Loop)
if (exitTarget != null && !pair.entry.Loop && !pair.entry.Terminal)
{
var exitTr = state.AddTransition(exitTarget);
exitTr.hasExitTime = true;
exitTr.exitTime = 1f;
exitTr.hasFixedDuration = false;
exitTr.duration = 0f;
}
}

Expand All @@ -248,6 +276,10 @@ internal static (string path, int stateCount) BuildController(
state.motion = pair.clip;
if (rootSM.defaultState == null)
rootSM.defaultState = state;
if (rootSM.defaultState != state)
diagnostics.AddWarning("STATE_UNREACHABLE",
$"Clip '{pair.entry.ClipName}' matches no action word, so no transition leads to its state: it plays only from a script, or after you rename the clip to an action word.",
"Rename the clip to include an action word such as attack, jump or hurt, then rebuild with overwrite=true.");
}

EditorUtility.SetDirty(controller);
Expand Down
7 changes: 7 additions & 0 deletions MCPForUnity/Editor/Tools/Sprite2D/SpriteFullSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ public static object Run(JObject @params, SpriteDiagnosticBuilder diagnostics)
bool overwrite = ParamCoercion.CoerceBool(@params["overwrite"], false);

var clips = SpriteClipBuilder.CreateClips(path, clipsToken, outputDir, overwrite, diagnostics);
// A repeated run skips every clip, and the controller step then failed with "No valid
// clips loaded.", which named neither the cause nor the way past it.
if (clips.Count == 0 && diagnostics.Build().Count(d => d.code == "CLIP_EXISTS") == clipsToken.Count)
diagnostics.AddError("ALL_CLIPS_EXIST",
"Every requested clip already exists, so no clip was written and full_setup stopped before the controller step.",
"Set overwrite=true to replace the clips and the controller.",
"Call setup_controller with the existing .anim paths, which the CLIP_EXISTS warnings name, and overwrite=true if the controller already exists.");
if (diagnostics.HasErrors)
return Stop("setup_clips", diagnostics);

Expand Down
45 changes: 42 additions & 3 deletions MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ public ImporterSnapshot(TextureImporter importer)
filterMode = importer.filterMode;
}

/// <summary>The frame names the sheet had before this call, in sheet order.</summary>
public string[] FrameNames => spritesheet.Select(s => s.name).ToArray();

public void Restore(TextureImporter importer)
{
bool changed = false;
Expand Down Expand Up @@ -234,6 +237,24 @@ public static object SliceSheet(JObject @params, SpriteDiagnosticBuilder diagnos
if (!rowsGiven && frameH <= 0)
rows = 1;

// Point unless asked: it keeps pixel art sharp, and it was the only filter slice_sheet
// set before this was a parameter. A switch rather than Enum.TryParse, which would
// also take "7" or "Bilinear,Trilinear", neither of them a filter.
FilterMode filterMode = FilterMode.Point;
JToken filterToken = @params["filter_mode"];
if (filterToken != null && filterToken.Type != JTokenType.Null)
{
switch (filterToken.ToString().ToLowerInvariant())
{
case "point": filterMode = FilterMode.Point; break;
case "bilinear": filterMode = FilterMode.Bilinear; break;
case "trilinear": filterMode = FilterMode.Trilinear; break;
default:
return diagnostics.Fail("BAD_PARAM",
$"'filter_mode' must be point, bilinear or trilinear; got '{filterToken}'.");
}
}

// Measure only once imported as a sprite sheet: a Default-type import rescales a
// non-power-of-two sheet (96px to 128px) and the trailing frames then land outside
// the real texture, where Unity drops them silently - measured on 6000.4.4f1, a
Expand All @@ -257,7 +278,7 @@ public static object SliceSheet(JObject @params, SpriteDiagnosticBuilder diagnos
EditorUtility.SetDirty(importer);
importer.SaveAndReimport();
}
return SliceConverted(@params, diagnostics, path, importer, snapshot, cols, rows, frameW, frameH);
return SliceConverted(@params, diagnostics, path, importer, snapshot, cols, rows, frameW, frameH, filterMode);
}
catch
{
Expand All @@ -270,7 +291,7 @@ public static object SliceSheet(JObject @params, SpriteDiagnosticBuilder diagnos

private static object SliceConverted(JObject @params, SpriteDiagnosticBuilder diagnostics, string path,
TextureImporter importer, ImporterSnapshot snapshot,
int cols, int rows, int frameW, int frameH)
int cols, int rows, int frameW, int frameH, FilterMode filterMode)
{
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
if (texture == null)
Expand Down Expand Up @@ -357,7 +378,7 @@ private static object SliceConverted(JObject @params, SpriteDiagnosticBuilder di

importer.spriteImportMode = SpriteImportMode.Multiple;
importer.spritesheet = metas;
importer.filterMode = FilterMode.Point; // pixel-perfect default
importer.filterMode = filterMode;
// Assigning spritesheet on an already-Multiple importer does not mark it dirty, so
// SaveAndReimport would restore the old grid - measured, a second slice did nothing.
EditorUtility.SetDirty(importer);
Expand All @@ -380,6 +401,24 @@ private static object SliceConverted(JObject @params, SpriteDiagnosticBuilder di
"Confirm the texture's import settings allow sprite generation");
}

// A sprite's ID follows its name, so a re-slice keeps only the frames whose names the
// new grid reuses. Measured on 2021.3.45f2: a clip of all eight frames of a 4x2 sheet
// had six of them missing after a 2x1 re-slice, and the response said nothing;
// slicing 4x2 again brought all eight back. After the generation check, because a
// refusal restores the old frames and the warning would then be false.
string[] before = snapshot.FrameNames;
string[] removed = before.Except(metas.Select(m => m.name)).ToArray();
if (removed.Length > 0)
{
const int MaxNamesListed = 10;
string names = string.Join(", ", removed.Take(MaxNamesListed))
+ (removed.Length > MaxNamesListed ? $" and {removed.Length - MaxNamesListed} more" : "");
diagnostics.AddWarning("SLICE_REMOVED_FRAMES",
$"This slice removed {removed.Length} of the {before.Length} frames the sheet had ({names}); animation clips that used them lose those frames.",
"If the frames are still needed, slice again with the previous grid and base_name; clips pick them up again by name",
"Otherwise rebuild the clips that used them: setup_clips or full_setup, with overwrite=true");
}

return new
{
success = true,
Expand Down
5 changes: 5 additions & 0 deletions MCPForUnity/Editor/Tools/Sprite2D/SpriteNamingDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ internal class SpriteAnimEntry
public bool Loop;
public string TriggerName;
public float BlendValue; // Position on the 1D blend tree: walk=1, run=2.
public bool Terminal; // A death: its one-shot state gets no exit and holds its last frame.
}

internal static class SpriteNamingDetector
Expand All @@ -39,6 +40,10 @@ private static void Categorize(string name, SpriteAnimEntry entry)
{
var words = Words(name);

// Whichever word picks the trigger: 'death_fall' falls on 'Fall' and is still a
// death, and an exit back to idle would stand the character up again.
entry.Terminal = Has(words, "die", "death");

if (Has(words, "idle", "stand"))
{ entry.Category = SpriteAnimCategory.Idle; return; }

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Full history: [Release Notes](https://coplaydev.github.io/unity-mcp/releases).

## What it does

Control the Unity Editor in natural language from any MCP client — create scenes & GameObjects, edit C# scripts, manage assets, run tests, profile, and build. 48 focused MCP tool entrypoints, any client, free & MIT.
Control the Unity Editor in natural language from any MCP client — create scenes & GameObjects, edit C# scripts, manage assets, run tests, profile, and build. 50 focused MCP tool entrypoints, any client, free & MIT.

**[Browse the full tool catalog →](https://coplaydev.github.io/unity-mcp/reference/tools/)**

Expand Down
3 changes: 3 additions & 0 deletions Server/src/cli/CLI_USAGE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,9 @@ unity-mcp sprite info "Assets/Sprites/Hero.png"
# Slice into a grid: --cols/--rows, or --frame-width/--frame-height
unity-mcp sprite slice "Assets/Sprites/Hero.png" --cols 6 --rows 4

# High-resolution art: --filter-mode bilinear or trilinear (the default, point, suits pixel art)
unity-mcp sprite slice "Assets/Sprites/Painted.png" --cols 8 --filter-mode bilinear

# Clips from the slices, then a controller from the clips
unity-mcp sprite setup-clips "Assets/Sprites/Hero.png" --clips '[{"name": "walk", "start_frame": 0, "end_frame": 5}]'
unity-mcp sprite setup-controller "Assets/Animators/Hero.controller" --clips '[{"name": "walk", "path": "Assets/Sprites/walk.anim"}]'
Expand Down
Loading
Loading