diff --git a/.claude/skills/unity-mcp-skill/SKILL.md b/.claude/skills/unity-mcp-skill/SKILL.md index 1c63b4de1..7c41a7fda 100644 --- a/.claude/skills/unity-mcp-skill/SKILL.md +++ b/.claude/skills/unity-mcp-skill/SKILL.md @@ -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). | diff --git a/MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs b/MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs index 0ca9c84de..65f10c4d3 100644 --- a/MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs +++ b/MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs @@ -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 ────────────────────────────────────────────────── @@ -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 ──────────────────────────────────────────────────── @@ -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 @@ -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; } } } @@ -212,6 +224,10 @@ 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(); foreach (var pair in triggerPairs) { var state = rootSM.AddState(pair.entry.ClipName); @@ -219,24 +235,36 @@ internal static (string path, int stateCount) BuildController( 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; } } @@ -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); diff --git a/MCPForUnity/Editor/Tools/Sprite2D/SpriteFullSetup.cs b/MCPForUnity/Editor/Tools/Sprite2D/SpriteFullSetup.cs index d1e87ca07..8be02d0df 100644 --- a/MCPForUnity/Editor/Tools/Sprite2D/SpriteFullSetup.cs +++ b/MCPForUnity/Editor/Tools/Sprite2D/SpriteFullSetup.cs @@ -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); diff --git a/MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs b/MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs index 7994573f5..788857848 100644 --- a/MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs +++ b/MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs @@ -184,6 +184,9 @@ public ImporterSnapshot(TextureImporter importer) filterMode = importer.filterMode; } + /// The frame names the sheet had before this call, in sheet order. + public string[] FrameNames => spritesheet.Select(s => s.name).ToArray(); + public void Restore(TextureImporter importer) { bool changed = false; @@ -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 @@ -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 { @@ -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(path); if (texture == null) @@ -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); @@ -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, diff --git a/MCPForUnity/Editor/Tools/Sprite2D/SpriteNamingDetector.cs b/MCPForUnity/Editor/Tools/Sprite2D/SpriteNamingDetector.cs index 736d61c14..2febc9f23 100644 --- a/MCPForUnity/Editor/Tools/Sprite2D/SpriteNamingDetector.cs +++ b/MCPForUnity/Editor/Tools/Sprite2D/SpriteNamingDetector.cs @@ -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 @@ -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; } diff --git a/README.md b/README.md index 10afbd7d1..8a487f0d5 100644 --- a/README.md +++ b/README.md @@ -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/)** diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index cba9a9321..fe330a061 100644 --- a/Server/src/cli/CLI_USAGE_GUIDE.md +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -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"}]' diff --git a/Server/src/cli/commands/sprite.py b/Server/src/cli/commands/sprite.py index 5d6582578..d0a645c8a 100644 --- a/Server/src/cli/commands/sprite.py +++ b/Server/src/cli/commands/sprite.py @@ -58,11 +58,16 @@ def info(path: str, page_size: Optional[int], cursor: Optional[int]): @click.option("--frame-width", type=int, default=None, help="Frame width in pixels; alternative to --cols.") @click.option("--frame-height", type=int, default=None, help="Frame height in pixels; alternative to --rows.") @click.option("--base-name", default=None, help="Base name for the frames (default: texture file name).") +@click.option("--filter-mode", type=click.Choice(["point", "bilinear", "trilinear"]), default=None, + help="Texture filter for the sheet (default point, for pixel art).") @handle_unity_errors def slice_sheet(path: str, cols: Optional[int], rows: Optional[int], frame_width: Optional[int], - frame_height: Optional[int], base_name: Optional[str]): + frame_height: Optional[int], base_name: Optional[str], filter_mode: Optional[str]): """Slice a sprite sheet into a grid of frames. + Replaces the sheet's existing slices and sets it to Sprite (Multiple) import, + with NPOT scaling off and the --filter-mode filter. + \b Examples: unity-mcp sprite slice Assets/Sprites/hero.png --cols 6 --rows 4 @@ -71,7 +76,7 @@ def slice_sheet(path: str, cols: Optional[int], rows: Optional[int], frame_width config = get_config() result = run_command("manage_sprite", _params( "slice_sheet", path=path, cols=cols, rows=rows, frame_width=frame_width, - frame_height=frame_height, base_name=base_name), config) + frame_height=frame_height, base_name=base_name, filter_mode=filter_mode), config) click.echo(format_output(result, config.format)) @@ -125,24 +130,35 @@ def setup_controller(controller_path: str, clips: str, overwrite: bool): @click.option("--frame-width", type=int, default=None, help="Frame width in pixels; alternative to --cols.") @click.option("--frame-height", type=int, default=None, help="Frame height in pixels; alternative to --rows.") @click.option("--base-name", default=None, help="Base name for the frames (default: texture file name).") +@click.option("--filter-mode", type=click.Choice(["point", "bilinear", "trilinear"]), default=None, + help="Texture filter for the sheet (default point, for pixel art).") @click.option("--clips", "clips", default=None, help='JSON list: [{"name","start_frame","end_frame","fps","loop"}].') -@click.option("--animation-name", default=None, help="Name of the single clip made when --clips is not given.") -@click.option("--output-dir", default=None, help="Folder for the .anim assets (default: the sheet's folder).") -@click.option("--controller-path", default=None, help="Path for the .controller asset.") -@click.option("--overwrite", is_flag=True, help="Replace .anim and .controller assets that already exist.") -@click.option("--add-to-scene", is_flag=True, help="Attach an Animator with the controller to --scene-target.") -@click.option("--scene-target", default=None, help="Existing GameObject that --add-to-scene attaches the Animator to.") +@click.option("--animation-name", default=None, + help="Name of the one clip made from all frames when --clips is not given (default: the " + "sheet's file name; 12 fps; loops only for idle, walk or run-type names).") +@click.option("--output-dir", default=None, + help="Folder for the .anim assets and, without --controller-path, the controller " + "(default: the sheet's folder).") +@click.option("--controller-path", default=None, + help="Path for the .controller asset (default: /_Controller.controller).") +@click.option("--overwrite", is_flag=True, + help="Replace .anim and .controller assets that already exist (the sheet is re-sliced either way).") +@click.option("--add-to-scene", is_flag=True, + help="Give --scene-target an Animator with the controller, and a SpriteRenderer if it has none.") +@click.option("--scene-target", default=None, + help="Name of exactly one existing GameObject (inactive ones count) for --add-to-scene.") @handle_unity_errors def full_setup(path: str, cols: Optional[int], rows: Optional[int], frame_width: Optional[int], frame_height: Optional[int], base_name: Optional[str], clips: Optional[str], animation_name: Optional[str], output_dir: Optional[str], controller_path: Optional[str], - overwrite: bool, add_to_scene: bool, scene_target: Optional[str]): + overwrite: bool, add_to_scene: bool, scene_target: Optional[str], filter_mode: Optional[str]): """Slice a sheet, then build its clips and controller in one step. \b Examples: - unity-mcp sprite full-setup Assets/Sprites/coin.png --cols 8 --animation-name spin + unity-mcp sprite full-setup Assets/Sprites/coin.png --cols 8 \\ + --clips '[{"name": "spin", "start_frame": 0, "end_frame": 7, "loop": true}]' unity-mcp sprite full-setup Assets/Sprites/hero.png --cols 6 --rows 4 \\ --clips '[{"name": "idle", "start_frame": 0, "end_frame": 5}, {"name": "walk", "start_frame": 6, "end_frame": 11}]' \\ --controller-path Assets/Animators/Hero.controller --add-to-scene --scene-target Hero @@ -150,7 +166,7 @@ def full_setup(path: str, cols: Optional[int], rows: Optional[int], frame_width: config = get_config() result = run_command("manage_sprite", _params( "full_setup", path=path, cols=cols, rows=rows, frame_width=frame_width, - frame_height=frame_height, base_name=base_name, clips=_clips(clips), + frame_height=frame_height, base_name=base_name, filter_mode=filter_mode, clips=_clips(clips), animation_name=animation_name, output_dir=output_dir, controller_path=controller_path, overwrite=overwrite or None, add_to_scene=add_to_scene or None, scene_target=scene_target), config) diff --git a/Server/src/services/registry/tool_registry.py b/Server/src/services/registry/tool_registry.py index 3b79a9478..db9986997 100644 --- a/Server/src/services/registry/tool_registry.py +++ b/Server/src/services/registry/tool_registry.py @@ -19,7 +19,7 @@ "core": "Essential scene, script, asset & editor tools (always on by default)", "docs": "Unity API reflection and documentation lookup", "vfx": "Visual effects – VFX Graph, shaders, procedural textures", - "animation": "Animator control & AnimationClip creation", + "animation": "Animator control, AnimationClip creation & 2D sprite-sheet animation", "ui": "UI Toolkit (UXML, USS, UIDocument)", "scripting_ext": "ScriptableObject management", "testing": "Test runner & async test jobs", diff --git a/Server/src/services/tools/manage_sprite.py b/Server/src/services/tools/manage_sprite.py index 6acf3732d..8044b7760 100644 --- a/Server/src/services/tools/manage_sprite.py +++ b/Server/src/services/tools/manage_sprite.py @@ -38,14 +38,15 @@ def _sprite_image_result(result: dict[str, Any], image_base64: str) -> ToolResul @mcp_for_unity_tool( group="animation", description=( - "2D sprite animation tool. " - "get_info: read sprite import settings and return the sheet as an image block for vision analysis; " - "the slice list is paged (page_size / cursor). " - "slice_sheet: apply grid slicing to a sprite sheet. " - "setup_clips: create AnimationClips from sliced sprites. " - "setup_controller: build AnimatorController with smart complexity (1D blend tree for locomotion, " - "trigger states for combat, simple state for single animations). " - "full_setup: one command — slice → clips → controller." + "Slice 2D sprite sheets and build AnimationClips and an AnimatorController from the frames. " + "Actions: get_info returns a sheet's import settings and slices (paged with page_size / cursor) " + "and, for a PNG or JPEG source, the sheet as an image block for vision analysis; " + "slice_sheet applies a grid, replacing the sheet's existing slices; " + "setup_clips creates AnimationClips from the slices; " + "setup_controller builds a controller from clip names (idle = default state, one walk/run " + "clip = a plain state and two or more = a Speed-driven 1D blend tree, jump/attack/hurt-type " + "names = trigger states that fire from any state, other names = plain states); " + "full_setup runs slice → clips → controller in one call." ), annotations=ToolAnnotations( title="Manage Sprite", @@ -79,32 +80,51 @@ async def manage_sprite( str | None, "Base name for sliced sprite frames (default: texture filename).", ] = None, + filter_mode: Annotated[ + Literal["point", "bilinear", "trilinear"] | None, + "slice_sheet and full_setup: texture filter the sliced sheet is imported with, in lowercase " + "(get_info reports it as Point, Bilinear or Trilinear). Default: point, which keeps pixel art " + "sharp; bilinear or trilinear suits high-resolution art. Every slice sets it, so a filter set " + "by hand does not survive a re-slice.", + ] = None, clips: Annotated[ list[dict[str, Any]] | None, - "Clip definitions: [{name, start_frame, end_frame, fps (default 12), loop (auto-detect if omitted)}]. " + "Clip definitions: [{name, start_frame (default 0), end_frame (default the last frame), " + "fps (default 12), loop (default from the name)}]. " "For setup_controller: [{name, path}] where path is the .anim asset path.", ] = None, animation_name: Annotated[ str | None, - "Animation name for full_setup when clips are not specified (all frames = one clip).", + "full_setup without clips: name of the one clip made from every frame at 12 fps " + "(default: the sheet's file name). It loops only for an idle, walk or run-type name.", ] = None, output_dir: Annotated[ str | None, - "Output directory for .anim and .controller assets (default: same folder as sprite).", + "setup_clips and full_setup: folder for the .anim assets, and for full_setup's default " + "controller (default: the sprite's folder).", ] = None, controller_path: Annotated[ str | None, - "Path for the .controller asset (e.g. 'Assets/Animators/Hero.controller').", + "Path for the .controller asset (e.g. 'Assets/Animators/Hero.controller'); '.controller' is " + "appended if missing. Required for setup_controller; full_setup defaults to " + "'/_Controller.controller'.", ] = None, overwrite: Annotated[ bool, - "Replace an existing .anim or .controller at the target path. Off by default: " - "without it an existing asset is kept and reported back, not silently replaced.", + "Replace an existing .anim or .controller at the target path. Off by default: an existing " + "clip is skipped with a CLIP_EXISTS warning, and an existing controller fails the call with " + "CONTROLLER_EXISTS. Slicing is not covered: it always replaces the sheet's slices.", + ] = False, + add_to_scene: Annotated[ + bool, + "full_setup: give scene_target an Animator with the new controller, replacing any controller " + "it had, and a SpriteRenderer if it has none (warning SCENE_SPRITE_RENDERER_ADDED).", ] = False, - add_to_scene: Annotated[bool, "Attach Animator + controller to a scene GameObject."] = False, scene_target: Annotated[ str | None, - "Existing GameObject name to attach Animator to.", + "full_setup with add_to_scene: name of exactly one existing GameObject (inactive ones count). " + "A missing, unmatched or ambiguous target fails at step 'add_to_scene', after the clips and " + "controller are written.", ] = None, # The numbers below are documentation, not enforcement: SpriteParams and # SpriteImportSetup.GetInfo are what actually refuse an out-of-range page_size, and @@ -118,7 +138,7 @@ async def manage_sprite( cursor: Annotated[ int | None, "get_info: index to start the 'slices' page at. Pass back the 'next_cursor' from " - "the previous response; absent next_cursor means the list is finished. The image " + "the previous response; next_cursor is null on the last page. The image " "is returned only on the first page.", ] = None, ) -> dict[str, Any] | ToolResult: @@ -151,7 +171,7 @@ async def manage_sprite( optional = { "path": path, "cols": cols, "rows": rows, "frame_width": frame_width, "frame_height": frame_height, - "base_name": base_name, "clips": clips, + "base_name": base_name, "filter_mode": filter_mode, "clips": clips, "animation_name": animation_name, "output_dir": output_dir, "controller_path": controller_path, "page_size": page_size, "cursor": cursor, "scene_target": scene_target, diff --git a/Server/tests/test_manage_sprite.py b/Server/tests/test_manage_sprite.py index ddf544997..6c8429d08 100644 --- a/Server/tests/test_manage_sprite.py +++ b/Server/tests/test_manage_sprite.py @@ -239,7 +239,7 @@ def test_every_optional_argument_has_a_forwarding_branch(self, mock_unity): # dropped branch and this guard would cry wolf. sample = { "path": "Assets/a.png", "cols": 1, "rows": 1, "frame_width": 1, - "frame_height": 1, "base_name": "b", "clips": [{"name": "walk"}], + "frame_height": 1, "base_name": "b", "filter_mode": "bilinear", "clips": [{"name": "walk"}], "animation_name": "walk", "output_dir": "Assets/out", "controller_path": "Assets/a.controller", "overwrite": True, "add_to_scene": True, "scene_target": "Hero", "page_size": 1, "cursor": 1, @@ -292,9 +292,10 @@ class TestSpriteCLICommands: {"action": "get_info", "path": "Assets/atlas.png", "page_size": 100, "cursor": 200}), (["slice", "Assets/hero.png", "--cols", "4"], {"action": "slice_sheet", "path": "Assets/hero.png", "cols": 4}), - (["slice", "Assets/hero.png", "--frame-width", "32", "--frame-height", "16", "--base-name", "hero"], + (["slice", "Assets/hero.png", "--frame-width", "32", "--frame-height", "16", "--base-name", "hero", + "--filter-mode", "bilinear"], {"action": "slice_sheet", "path": "Assets/hero.png", "frame_width": 32, "frame_height": 16, - "base_name": "hero"}), + "base_name": "hero", "filter_mode": "bilinear"}), (["setup-clips", "Assets/hero.png", "--clips", '[{"name": "walk", "start_frame": 0, "end_frame": 5}]'], {"action": "setup_clips", "path": "Assets/hero.png", "clips": [{"name": "walk", "start_frame": 0, "end_frame": 5}]}), @@ -324,7 +325,7 @@ def test_every_tool_parameter_can_be_sent_from_the_cli(self, run_cli): ["full-setup", "Assets/a.png", "--cols", "1", "--rows", "1", "--frame-width", "1", "--frame-height", "1", "--base-name", "b", "--clips", "[]", "--animation-name", "walk", "--output-dir", "Assets/out", "--controller-path", "Assets/a.controller", "--overwrite", - "--add-to-scene", "--scene-target", "Hero"], + "--add-to-scene", "--scene-target", "Hero", "--filter-mode", "point"], ): result, mock_run = run_cli(args) assert result.exit_code == 0, result.output diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs index f7e38ab40..077feecb5 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs @@ -697,6 +697,25 @@ public void SliceSheet_FrameHeightAloneDerivesTheRowCount() Assert.AreEqual(16, SpritesOf(path).Length); } + [TestCase("slice_sheet", null, FilterMode.Point)] + [TestCase("slice_sheet", "bilinear", FilterMode.Bilinear)] + // Mixed case, and through full_setup, which hands its own params to the slice step. + [TestCase("full_setup", "Trilinear", FilterMode.Trilinear)] + public void FilterMode_IsWhatTheSliceSets_PointUnlessAsked(string action, string filterMode, FilterMode expected) + { + string path = CreateSheet("filter", 4, 1); + Assert.AreEqual(FilterMode.Bilinear, ((TextureImporter)AssetImporter.GetAtPath(path)).filterMode, + "fixture: a sheet that starts as Point would let the Point case pass without the slice setting it"); + var request = new JObject { ["action"] = action, ["path"] = path, ["cols"] = 4 }; + if (filterMode != null) + request["filter_mode"] = filterMode; + + var result = Run(request); + + Assert.AreEqual(expected, ((TextureImporter)AssetImporter.GetAtPath(path)).filterMode, + result.ToString(Newtonsoft.Json.Formatting.None)); + } + private static IEnumerable RefusedGrids() { TestCaseData Case(int sheetCols, int sheetRows, JObject grid, string code) => @@ -723,6 +742,8 @@ TestCaseData Case(int sheetCols, int sheetRows, JObject grid, string code) => yield return Case(8, 8, new JObject { ["cols"] = 128, ["rows"] = 128 }, "SLICE_TOO_MANY_FRAMES"); // A negative alternative used to be silently replaced by the value derived from cols. yield return Case(2, 1, new JObject { ["cols"] = 2, ["frame_width"] = -1 }, "BAD_PARAM"); + // "nearest" is another engine's name for Point: refused like a bad grid value, not mapped. + yield return Case(2, 1, new JObject { ["cols"] = 2, ["filter_mode"] = "nearest" }, "BAD_PARAM"); } [TestCaseSource(nameof(RefusedGrids))] @@ -810,7 +831,7 @@ public void SliceSheet_ReslicingWithADifferentGrid_ReplacesTheOldFrames() Slice(path, 4, 2); Assert.AreEqual(8, SpritesOf(path).Length); - Slice(path, 2, 1); + var smaller = Slice(path, 2, 1); var after = SpritesOf(path).Select(s => s.name).ToArray(); #pragma warning disable CS0618 // same API the tool writes through int configured = ((TextureImporter)AssetImporter.GetAtPath(path)).spritesheet.Length; @@ -818,6 +839,11 @@ public void SliceSheet_ReslicingWithADifferentGrid_ReplacesTheOldFrames() Assert.AreEqual(2, after.Length, $"stale frames must not survive a reslice; importer holds {configured}, " + "project holds: " + string.Join(", ", after)); + // A clip that played reslice_2..7 loses those frames, so the slice has to name them. + Assert.That(smaller["diagnostics"].ToString(), + Does.Contain("SLICE_REMOVED_FRAMES").And.Contain("reslice_7").And.Not.Contain("reslice_1")); + Assert.That(Slice(path, 2, 1)["diagnostics"].ToString(), Does.Not.Contain("SLICE_REMOVED_FRAMES"), + "the same grid again removes nothing"); } // ===================================================================== @@ -1239,6 +1265,65 @@ public void SetupController_OneShotsWithoutALoopingState_DoNotExitIntoEachOther( $"'{state.name}' got an exit-time transition"); } + [Test] + public void SetupController_TriggersFireFromAnyStateWithoutBlending() + { + // Trigger transitions used to come only from states that already existed, so + // 'attack' could not interrupt 'hurt', which is built after it. + var result = SetupController(BuildClips("anystate", "idle", "walk", "attack", "hurt")); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var sm = AssetDatabase.LoadAssetAtPath($"{TempRoot}/Hero.controller").layers[0].stateMachine; + foreach (var (state, trigger) in new[] { ("attack", "Attack"), ("hurt", "Hurt") }) + Assert.IsTrue(sm.anyStateTransitions.Any(t => + t.destinationState != null && t.destinationState.name == state && + t.conditions.Any(c => c.mode == AnimatorConditionMode.If && c.parameter == trigger)), + $"'{state}' needs an Any State transition on '{trigger}'; without one, states built " + + "after it cannot be interrupted by it ('attack' could not interrupt 'hurt')"); + Assert.That(sm.anyStateTransitions.Where(t => !t.canTransitionToSelf).Select(t => t.destinationState?.name), + Is.Empty, "a repeated trigger must restart its clip; with canTransitionToSelf off, Unity keeps " + + "the trigger set and replays the state after it ends"); + + var blended = sm.states + .SelectMany(s => s.state.transitions.Select(t => (source: s.state.name, t))) + .Concat(sm.anyStateTransitions.Select(t => (source: "Any State", t))) + .Where(x => x.t.duration != 0f) + .Select(x => $"{x.source} -> {x.t.destinationState?.name} ({x.t.duration})") + .ToArray(); + Assert.That(blended, Is.Empty, + "sprite keys cannot blend, so any blend time only delays the frame change"); + } + + // Each case leaves one clip that no transition plays, so only a warning tells the caller. + // `named`: what the warning must name, the clip it is about first. + [TestCase("STATE_UNREACHABLE", "idle,taunt", "'taunt'")] + // One Idle state: the second idle clip was dropped without a word. + [TestCase("IDLE_CLIP_UNUSED", "idle,idle_blink", "'idle_blink'", "'idle'")] + // Both clips got an Any State transition on Attack, and only the first could ever fire. + [TestCase("TRIGGER_SHARED", "idle,attack,hero_attack", "'hero_attack'", "'attack'", "'Attack'")] + public void SetupController_ClipThatNoTransitionPlays_IsNamedInAWarning(string code, string clips, params string[] named) + { + var result = SetupController(BuildClips("unplayed", clips.Split(','))); + Assert.IsTrue(result.Value("success"), result.ToString()); + + // Not even a transition that can never fire: for a shared trigger the builder used to + // add a second Any State transition anyway. + string clip = named[0].Trim('\''); + var sm = AssetDatabase.LoadAssetAtPath($"{TempRoot}/Hero.controller").layers[0].stateMachine; + var incoming = sm.anyStateTransitions + .Concat(sm.states.SelectMany(s => s.state.transitions)) + .Where(t => t.destinationState != null && t.destinationState.name == clip); + Assert.That(incoming, Is.Empty, $"a transition leads to '{clip}'"); + + var warnings = result["diagnostics"] + .Where(d => d.Value("code") == code) + .Select(d => d.Value("message")) + .ToArray(); + Assert.AreEqual(1, warnings.Length, "diagnostics were " + result["diagnostics"]); + foreach (string name in named) + Assert.That(warnings[0], Does.Contain(name)); + } + [Test] public void SetupController_WalkAndRun_BuildsASpeedDrivenBlendTree() { @@ -1374,8 +1459,13 @@ public void SetupController_OverwriteThatCannotBuildAReplacement_KeepsTheOldCont // full_setup // ===================================================================== - [Test] - public void FullSetup_ControllerRefusal_StopsBeforeTouchingTheScene() + // A second run without overwrite. Given the same clip, every clip exists: it used to reach + // the controller step and fail there with "No valid clips loaded.". Given a new clip, the + // clip is written and the existing controller refuses. `fixes`: what the refusal must offer. + [TestCase(null, "setup_clips", "ALL_CLIPS_EXIST", "overwrite=true", "setup_controller")] + [TestCase("s5_new", "setup_controller", "CONTROLLER_EXISTS", "overwrite=true")] + public void FullSetup_RerunWithoutOverwrite_StopsAtTheRefusingStepBeforeTouchingTheScene( + string secondClip, string step, string code, params string[] fixes) { string path = CreateSheet("s5", 4, 1); var go = new GameObject("SpriteTest_S5"); @@ -1385,17 +1475,23 @@ public void FullSetup_ControllerRefusal_StopsBeforeTouchingTheScene() Run(new JObject { ["action"] = "full_setup", ["path"] = path, ["cols"] = 4, ["output_dir"] = TempRoot, ["controller_path"] = ctrl }); - // Second run: the controller exists and overwrite is not set, so the - // controller step fails - and a failed step must not fall through. - var result = Run(new JObject { ["action"] = "full_setup", ["path"] = path, ["cols"] = 4, - ["output_dir"] = TempRoot, ["controller_path"] = ctrl, - ["add_to_scene"] = true, ["scene_target"] = "SpriteTest_S5" }); + var rerun = new JObject { ["action"] = "full_setup", ["path"] = path, ["cols"] = 4, + ["output_dir"] = TempRoot, ["controller_path"] = ctrl, + ["add_to_scene"] = true, ["scene_target"] = "SpriteTest_S5" }; + if (secondClip != null) rerun["animation_name"] = secondClip; + var result = Run(rerun); Assert.IsFalse(result.Value("success")); - Assert.AreEqual("setup_controller", result.Value("step"), - "the response must name the step that failed"); + Assert.AreEqual(step, result.Value("step"), + "the response must name the step that refused; it was " + result); + var refusal = result["diagnostics"].FirstOrDefault(d => d.Value("code") == code); + Assert.IsNotNull(refusal, "diagnostics were " + result["diagnostics"]); + Assert.AreEqual("error", refusal.Value("severity")); + string offered = string.Join(" ", refusal["fix_options"].Values()); + foreach (string fix in fixes) + Assert.That(offered, Does.Contain(fix)); Assert.IsNull(go.GetComponent(), - "a refused controller step must not go on to modify the scene"); + "a refused step must not go on to modify the scene"); } finally { Object.DestroyImmediate(go); } } @@ -1544,28 +1640,31 @@ public void FullSetup_RefusedClip_IsNotCountedAsCreated() } // The controller re-derived looping from the clip name, so 'attack' with loop=true - // still got a one-shot exit to idle while its .anim looped. - [TestCase(true, false)] - [TestCase(null, true)] - public void FullSetup_ExplicitLoop_DecidesTheOneShotExit(bool? loop, bool expectExit) + // still got a one-shot exit to idle while its .anim looped. A death got that exit too, + // and returning to idle stood the dead character back up. + [TestCase("attack", true, false)] + [TestCase("attack", null, true)] + [TestCase("die", null, false)] + [TestCase("hero_death", null, false)] + public void FullSetup_LoopAndName_DecideTheOneShotExit(string clipName, bool? loop, bool expectExit) { string path = CreateSheet("loopflag", 4, 1); - var attackDef = new JObject { ["name"] = "attack", ["start_frame"] = 2, ["end_frame"] = 3 }; - if (loop.HasValue) attackDef["loop"] = loop.Value; + var oneShotDef = new JObject { ["name"] = clipName, ["start_frame"] = 2, ["end_frame"] = 3 }; + if (loop.HasValue) oneShotDef["loop"] = loop.Value; var result = Run(new JObject { ["action"] = "full_setup", ["path"] = path, ["cols"] = 4, ["output_dir"] = TempRoot, ["controller_path"] = $"{TempRoot}/Loop.controller", ["clips"] = new JArray { new JObject { ["name"] = "idle", ["start_frame"] = 0, ["end_frame"] = 1 }, - attackDef, + oneShotDef, }, }); Assert.IsTrue(result.Value("success"), result.ToString()); var sm = AssetDatabase.LoadAssetAtPath($"{TempRoot}/Loop.controller").layers[0].stateMachine; - var attack = sm.states.Select(s => s.state).Single(s => s.name == "attack"); - bool exitsToIdle = attack.transitions.Any(t => t.destinationState != null && t.destinationState.name == "Idle"); + var oneShot = sm.states.Select(s => s.state).Single(s => s.name == clipName); + bool exitsToIdle = oneShot.transitions.Any(t => t.destinationState != null && t.destinationState.name == "Idle"); Assert.AreEqual(expectExit, exitsToIdle); } diff --git a/docs/i18n/README-zh.md b/docs/i18n/README-zh.md index 35e1f3e42..ac57ff9fe 100644 --- a/docs/i18n/README-zh.md +++ b/docs/i18n/README-zh.md @@ -39,7 +39,7 @@ ## 它能做什么 -用自然语言从任意 MCP 客户端操作 Unity 编辑器:搭场景、建 GameObject、写改 C# 脚本、调材质和着色器、跑测试、看性能、出包。47 个 MCP 工具入口,任意客户端可用,免费、MIT 开源。 +用自然语言从任意 MCP 客户端操作 Unity 编辑器:搭场景、建 GameObject、写改 C# 脚本、调材质和着色器、跑测试、看性能、出包。50 个 MCP 工具入口,任意客户端可用,免费、MIT 开源。 **[查看完整工具目录 →](https://coplaydev.github.io/unity-mcp/reference/tools/)** diff --git a/unity-mcp-skill/SKILL.md b/unity-mcp-skill/SKILL.md index 38d3c8935..743d47feb 100644 --- a/unity-mcp-skill/SKILL.md +++ b/unity-mcp-skill/SKILL.md @@ -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). | | **Physics** | `manage_physics` | Manage 3D and 2D physics (21 actions). Settings, collision matrix, materials, joints (14 types). Queries: `raycast`, `raycast_all`, `linecast`, `shapecast` (sphere/box/capsule sweep), `overlap`. Forces: `apply_force` (AddForce/AddTorque/AddExplosionForce with ForceMode). Rigidbody: `get_rigidbody`, `configure_rigidbody` (mass, drag, gravity, constraints, collision detection). Validation: scene-wide checks. Simulation: `simulate_step` in edit mode. See [tools-reference.md](references/tools-reference.md#physics-tools). | diff --git a/website/docs/guides/cli-examples.md b/website/docs/guides/cli-examples.md index c67971cf3..89c508c54 100644 --- a/website/docs/guides/cli-examples.md +++ b/website/docs/guides/cli-examples.md @@ -224,7 +224,7 @@ unity-mcp texture delete "Assets/Textures/Old.png" [--force] ```bash unity-mcp sprite info "Assets/Sprites/Hero.png" # Size, import settings, slices unity-mcp sprite slice "Assets/Sprites/Hero.png" --cols 6 --rows 4 # Or --frame-width/--frame-height -unity-mcp sprite full-setup "Assets/Sprites/Coin.png" --cols 8 --animation-name spin +unity-mcp sprite full-setup "Assets/Sprites/Coin.png" --cols 8 --clips '[{"name":"spin","start_frame":0,"end_frame":7,"loop":true}]' unity-mcp sprite full-setup "Assets/Sprites/Hero.png" --cols 6 --rows 4 --clips '[{"name":"idle","start_frame":0,"end_frame":5},{"name":"walk","start_frame":6,"end_frame":11}]' --controller-path "Assets/Animators/Hero.controller" ``` diff --git a/website/docs/guides/cli.md b/website/docs/guides/cli.md index 46738a7bb..168976d0e 100644 --- a/website/docs/guides/cli.md +++ b/website/docs/guides/cli.md @@ -476,9 +476,10 @@ unity-mcp texture delete "Assets/Textures/Old.png" [--force] ```bash unity-mcp sprite info "Assets/Sprites/Hero.png" # Size, import settings, slices unity-mcp sprite slice "Assets/Sprites/Hero.png" --cols 6 --rows 4 # Or --frame-width/--frame-height +unity-mcp sprite slice "Assets/Sprites/Painted.png" --cols 8 --filter-mode bilinear # Default point, for pixel art 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"}]' -unity-mcp sprite full-setup "Assets/Sprites/Coin.png" --cols 8 --animation-name spin +unity-mcp sprite full-setup "Assets/Sprites/Coin.png" --cols 8 --clips '[{"name": "spin", "start_frame": 0, "end_frame": 7, "loop": true}]' ``` ### Build Operations diff --git a/website/docs/guides/tool-groups.md b/website/docs/guides/tool-groups.md index b75bc5f61..4223d4ea5 100644 --- a/website/docs/guides/tool-groups.md +++ b/website/docs/guides/tool-groups.md @@ -3,19 +3,19 @@ id: tool-groups slug: /guides/tool-groups title: Tool Groups and manage_tools sidebar_label: Tool Groups -description: Per-session visibility for the 48 tools. Activate vfx, animation, ui, testing, etc. only when you need them. +description: Per-session visibility for the 50 tools. Activate vfx, animation, ui, testing, etc. only when you need them. --- # Tool Groups -MCP for Unity ships 48 tools, but exposing all of them to the LLM at once balloons the prompt and dilutes routing decisions. So tools are sorted into **groups**, and only `core` is enabled by default. +MCP for Unity ships 50 tools, but exposing all of them to the LLM at once balloons the prompt and dilutes routing decisions. So tools are sorted into **groups**, and only `core` is enabled by default. ## The groups | Group | Default | Description | |---|---|---| | `core` | enabled | Essential scene, script, asset, and editor tools — always on. | -| `animation` | off | Animator control, AnimationClip creation. | +| `animation` | off | Animator control, AnimationClip creation, 2D sprite-sheet animation. | | `ui` | off | UI Toolkit — UXML, USS, UIDocument. | | `vfx` | off | VFX Graph, shaders, procedural textures. | | `scripting_ext` | off | ScriptableObject management. | @@ -65,7 +65,7 @@ Useful when a group's tools are confusing the assistant — e.g., `manage_shader Three reasons: 1. **Prompt economy**: each visible tool adds tokens to every assistant call. Hiding what you're not using is real money saved at scale. -2. **Routing clarity**: when the LLM picks between 48 tools versus the 30 core tools, the wrong-tool rate drops measurably. +2. **Routing clarity**: when the LLM picks between 50 tools versus the 30 core tools, the wrong-tool rate drops measurably. 3. **Package hygiene**: tools in `probuilder` only work if `com.unity.probuilder` is installed; hiding them by default avoids confusing errors. ## Server vs. session state diff --git a/website/docs/reference/tools/animation/index.md b/website/docs/reference/tools/animation/index.md index 0c76eae1f..35bb1893f 100644 --- a/website/docs/reference/tools/animation/index.md +++ b/website/docs/reference/tools/animation/index.md @@ -6,7 +6,7 @@ description: "MCP for Unity tools in the animation group." # `animation` tools -Animator control & AnimationClip creation +Animator control, AnimationClip creation & 2D sprite-sheet animation - **[`manage_animation`](./manage_animation.md)** — Manage Unity animation: Animator control and AnimationClip creation. -- **[`manage_sprite`](./manage_sprite.md)** — 2D sprite animation tool. get_info: read sprite import settings and return the sheet as an image block for vision analysis; the slice list is paged (page_size / cursor). slice_sheet: apply grid slicing to a sprite sheet. setup_clips: cre… +- **[`manage_sprite`](./manage_sprite.md)** — Slice 2D sprite sheets and build AnimationClips and an AnimatorController from the frames. diff --git a/website/docs/reference/tools/animation/manage_sprite.md b/website/docs/reference/tools/animation/manage_sprite.md index a522c9c3f..78c67335e 100644 --- a/website/docs/reference/tools/animation/manage_sprite.md +++ b/website/docs/reference/tools/animation/manage_sprite.md @@ -1,7 +1,7 @@ --- title: manage_sprite sidebar_label: manage_sprite -description: "2D sprite animation tool. get_info: read sprite import settings and return the sheet as an image block for vision analysis; the slice list is paged (page_size / cursor). slice_sheet: apply grid slicing to a sprite sheet. setup_clips: cre…" +description: "Slice 2D sprite sheets and build AnimationClips and an AnimatorController from the frames." --- # `manage_sprite` @@ -12,7 +12,7 @@ description: "2D sprite animation tool. get_info: read sprite import settings an ## Description -2D sprite animation tool. get_info: read sprite import settings and return the sheet as an image block for vision analysis; the slice list is paged (page_size / cursor). slice_sheet: apply grid slicing to a sprite sheet. setup_clips: create AnimationClips from sliced sprites. setup_controller: build AnimatorController with smart complexity (1D blend tree for locomotion, trigger states for combat, simple state for single animations). full_setup: one command — slice → clips → controller. +Slice 2D sprite sheets and build AnimationClips and an AnimatorController from the frames. Actions: get_info returns a sheet's import settings and slices (paged with page_size / cursor) and, for a PNG or JPEG source, the sheet as an image block for vision analysis; slice_sheet applies a grid, replacing the sheet's existing slices; setup_clips creates AnimationClips from the slices; setup_controller builds a controller from clip names (idle = default state, one walk/run clip = a plain state and two or more = a Speed-driven 1D blend tree, jump/attack/hurt-type names = trigger states that fire from any state, other names = plain states); full_setup runs slice → clips → controller in one call. ## Parameters @@ -25,15 +25,16 @@ description: "2D sprite animation tool. get_info: read sprite import settings an | `frame_width` | `int \| None` | — | Frame width in pixels. Alternative to cols. | | `frame_height` | `int \| None` | — | Frame height in pixels. Alternative to rows. | | `base_name` | `str \| None` | — | Base name for sliced sprite frames (default: texture filename). | -| `clips` | `list[dict[str, Any]] \| None` | — | Clip definitions: [{name, start_frame, end_frame, fps (default 12), loop (auto-detect if omitted)}]. For setup_controller: [{name, path}] where path is the .anim asset path. | -| `animation_name` | `str \| None` | — | Animation name for full_setup when clips are not specified (all frames = one clip). | -| `output_dir` | `str \| None` | — | Output directory for .anim and .controller assets (default: same folder as sprite). | -| `controller_path` | `str \| None` | — | Path for the .controller asset (e.g. 'Assets/Animators/Hero.controller'). | -| `overwrite` | `bool` | — | Replace an existing .anim or .controller at the target path. Off by default: without it an existing asset is kept and reported back, not silently replaced. | -| `add_to_scene` | `bool` | — | Attach Animator + controller to a scene GameObject. | -| `scene_target` | `str \| None` | — | Existing GameObject name to attach Animator to. | +| `filter_mode` | `Literal['point', 'bilinear', 'trilinear'] \| None` | — | slice_sheet and full_setup: texture filter the sliced sheet is imported with, in lowercase (get_info reports it as Point, Bilinear or Trilinear). Default: point, which keeps pixel art sharp; bilinear or trilinear suits high-resolution art. Every slice sets it, so a filter set by hand does not survive a re-slice. | +| `clips` | `list[dict[str, Any]] \| None` | — | Clip definitions: [{name, start_frame (default 0), end_frame (default the last frame), fps (default 12), loop (default from the name)}]. For setup_controller: [{name, path}] where path is the .anim asset path. | +| `animation_name` | `str \| None` | — | full_setup without clips: name of the one clip made from every frame at 12 fps (default: the sheet's file name). It loops only for an idle, walk or run-type name. | +| `output_dir` | `str \| None` | — | setup_clips and full_setup: folder for the .anim assets, and for full_setup's default controller (default: the sprite's folder). | +| `controller_path` | `str \| None` | — | Path for the .controller asset (e.g. 'Assets/Animators/Hero.controller'); '.controller' is appended if missing. Required for setup_controller; full_setup defaults to '/_Controller.controller'. | +| `overwrite` | `bool` | — | Replace an existing .anim or .controller at the target path. Off by default: an existing clip is skipped with a CLIP_EXISTS warning, and an existing controller fails the call with CONTROLLER_EXISTS. Slicing is not covered: it always replaces the sheet's slices. | +| `add_to_scene` | `bool` | — | full_setup: give scene_target an Animator with the new controller, replacing any controller it had, and a SpriteRenderer if it has none (warning SCENE_SPRITE_RENDERER_ADDED). | +| `scene_target` | `str \| None` | — | full_setup with add_to_scene: name of exactly one existing GameObject (inactive ones count). A missing, unmatched or ambiguous target fails at step 'add_to_scene', after the clips and controller are written. | | `page_size` | `int \| None` | — | get_info: how many entries of the 'slices' list to return (1-4096, default 512). A sheet sliced by hand can hold more slices than one response should carry. | -| `cursor` | `int \| None` | — | get_info: index to start the 'slices' page at. Pass back the 'next_cursor' from the previous response; absent next_cursor means the list is finished. The image is returned only on the first page. | +| `cursor` | `int \| None` | — | get_info: index to start the 'slices' page at. Pass back the 'next_cursor' from the previous response; next_cursor is null on the last page. The image is returned only on the first page. | ## Returns @@ -52,9 +53,14 @@ the frames before committing to a grid. { "action": "get_info", "path": "Assets/Sprites/hero_walk.png" } ``` +`width` and `height` are the source file's pixels, which `slice_sheet` cuts in; Max Size or +NPOT scaling can make the imported texture smaller. The image is left out, with the reason +in `image_omitted_reason`, when the source is not PNG or JPEG, is over 8000 px on a side, or +would exceed 4 MB as base64 (about a 3 MB file). + The `slices` list is paged. A sheet can hold more entries than one response should carry — `slice_sheet` alone allows up to 4096 — so `slice_count` reports the total and -`next_cursor` appears only while entries remain. Follow it whenever it is present rather +`next_cursor` is non-null only while entries remain. Follow it until it is null rather than assuming a sheet arrives whole. Walk it by passing the previous `next_cursor` back; the image comes with the first page only, since it is the same picture on every one. @@ -83,10 +89,24 @@ only, since it is the same picture on every one. } ``` -Clip names decide the controller's shape: `idle` becomes the default state, `walk` and -`run` collapse into a `Speed`-driven 1D blend tree, and `attack` gets an `Attack` trigger. -Looping follows from the same names — locomotion and idle loop, a one-shot does not — and -an explicit `"loop"` on a clip overrides that. +Clip names decide the controller's shape: here `idle` becomes the default state, `walk` and +`run` share a `Speed`-driven 1D blend tree, and `attack` gets an `Attack` trigger. A name is +split into words (on `_`, `-`, spaces, camelCase and digits, so `heroAttack2` reads as +`hero`, `attack`, `2`), and the first row that matches decides: + +| Words in the clip name | State | Loops by default | +|---|---|---| +| `idle`, `stand` | `Idle`, the default state. Only the first such clip is used: each later one gets no state, and the response warns `IDLE_CLIP_UNUSED`. | yes | +| `walk`; `run`, `sprint` | One clip: a state of that name. Two or more: a `Locomotion` state with a 1D blend tree on `Speed` (walk at 1, run at 2). Idle switches to it when `Speed` rises above 0.1 and back when it drops below 0.1. | yes | +| `jump`, `fall`, `land`; `attack`, `slash`, `punch`, `combo`, `cast`, `shoot`; `open`, `close`, `activate`, `die`, `death`, `hurt`, `hit` | A state entered from any state by a trigger named after the first of these words it contains (`heroAttack` → `Attack`); the trigger also restarts it. A non-looping one returns to Idle, else Locomotion, when it ends; a death, whose name has `die` or `death` in it, stays on its last frame instead. | no | +| anything else | A state no transition leads to. Unless it is the default state, it plays only from a script, and the response warns `STATE_UNREACHABLE`. | no | + +Every transition is instant, since sprite frames cannot blend. Each trigger enters one state: +when clips share a word, the first of them takes the trigger, and each later one gets a state +that no transition leads to, with a `TRIGGER_SHARED` warning, so give each one-shot its own +action word. A death has no exit of its own, but triggers fire from any state: one set after +the death, `Hurt` included, still takes the Animator out of it, so stop setting them once the +character is dead. An explicit `"loop"` on a clip overrides the default. ### Slicing on its own @@ -96,16 +116,45 @@ an explicit `"loop"` on a clip overrides that. `frame_width`/`frame_height` are the alternative to `cols`/`rows`; supply either pair. A grid that does not fit inside the texture is refused rather than silently dropping the -frames that fall outside it. +frames that fall outside it. Slicing sets the texture to Sprite (Multiple) import with NPOT +scaling off. +The sheet is imported with point filtering, which keeps pixel art sharp; pass +`"filter_mode": "bilinear"` (or `"trilinear"`) for high-resolution art. ### Replacing what is already there -Existing `.anim` and `.controller` assets are kept unless `overwrite` is set, so a repeated -`full_setup` reports what it found instead of overwriting work: +`overwrite` covers the `.anim` and `.controller` files, not the sheet. Without it, an +existing clip is skipped with a `CLIP_EXISTS` warning and an existing controller stops the +call with `CONTROLLER_EXISTS`. Repeating a `full_setup` therefore ends with +`success: false`: at `step: "setup_clips"` with `ALL_CLIPS_EXIST` when every clip already +exists, or at `step: "setup_controller"` with `CONTROLLER_EXISTS` once the new clips are +written. To replace what exists, pass `"overwrite": true`: ```json { "action": "setup_clips", "path": "Assets/Sprites/hero.png", "clips": [{ "name": "walk", "start_frame": 0, "end_frame": 5 }], "overwrite": true } ``` + +To keep the clips and only rebuild the controller, call `setup_controller` with their +`.anim` paths, which the `CLIP_EXISTS` warnings name, and `"overwrite": true` if the +controller already exists. + +Slicing has no such guard: every `slice_sheet` and `full_setup` replaces the sheet's slices +and sets its filter to `filter_mode` (point unless given), so a filter set by hand does not +survive. A frame keeps its identity only while the new grid reuses its name: a re-slice that +removes frames warns `SLICE_REMOVED_FRAMES`, and clips that used them lose those frames. +Slice again with the previous grid and `base_name` to bring them back, or rebuild the clips +with `overwrite`. + +### Reading the result + +Every action except `get_info` returns `diagnostics`, a list of +`{code, severity, message, fix_options}`. A bad clip entry (`CLIP_BAD_RANGE`, +`CLIP_BAD_FPS`, `CLIP_EXISTS` and the like) is a warning that skips that clip, so +`setup_clips` can succeed with `clip_count: 0`; compare `clip_count` with the clips you +sent. A failed `full_setup` names the step it stopped at in `step`: `slice_sheet`, +`setup_clips`, `setup_controller` or `add_to_scene`. At `add_to_scene` the clips and +controller are already written, and the response still carries `controller_path` and +`clip_count`. diff --git a/website/docs/reference/tools/index.md b/website/docs/reference/tools/index.md index 319f7d0e4..c3d40d57f 100644 --- a/website/docs/reference/tools/index.md +++ b/website/docs/reference/tools/index.md @@ -13,9 +13,9 @@ description: Auto-generated catalog of every MCP for Unity tool, grouped by doma Every tool MCP for Unity exposes, generated directly from the Python `@mcp_for_unity_tool` registry under `Server/src/services/tools/`. ## `animation`   (2 tools) -Animator control & AnimationClip creation +Animator control, AnimationClip creation & 2D sprite-sheet animation - **[`manage_animation`](./animation/manage_animation.md)** — Manage Unity animation: Animator control and AnimationClip creation. -- **[`manage_sprite`](./animation/manage_sprite.md)** — 2D sprite animation tool. get_info: read sprite import settings and return the sheet as an image block for vision analysis; the slice list is paged (page_size / cursor). slice_sheet: apply grid slicing to a sprite sheet. setup_clips: cre… +- **[`manage_sprite`](./animation/manage_sprite.md)** — Slice 2D sprite sheets and build AnimationClips and an AnimatorController from the frames. ## `asset_gen`   (6 tools) AI asset generation – 3D model gen/import, 2D image gen & audio gen (bring-your-own-key)