From 066d10842b44a3653069eaac0c8a01c835ee6268 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:46:43 -0400 Subject: [PATCH 1/7] fix(sprite): fire controller triggers from Any State, drop blend times setup_controller (and full_setup, which calls it) left every transition at Unity's default duration of 0.1. Sprite keys are object references, which cannot blend, so the blend only delayed the visible sprite change. Every transition the builder makes now has duration 0: idle<->locomotion in both the single-clip and blend-tree variants, trigger entries, and one-shot exits, which keep hasExitTime with exitTime 1. Trigger states got incoming transitions only from the states that existed when each one was created. With attack + hurt, hurt could interrupt attack but attack could not interrupt hurt, and generic states, created last, got no trigger transitions at all. Each trigger state now gets one Any State transition on its trigger. canTransitionToSelf stays on, so a repeated trigger restarts the clip and is consumed. Stepping an Animator with it off showed the repeat left set for the rest of the clip, then replaying the attack one frame after it returned to idle. A clip whose name matches no action word still gets a state that no transition leads to. Unless that state is the default, the builder now warns STATE_UNREACHABLE, naming the clip and how to make it reachable. The tool description and the manage_sprite reference page now say triggers fire from any state; the page also covers instant transitions and the new warning. --- .../Tools/Sprite2D/SpriteControllerBuilder.cs | 25 +++++++++---- Server/src/services/tools/manage_sprite.py | 2 +- .../Tests/EditMode/Tools/ManageSpriteTests.cs | 35 +++++++++++++++++++ .../tools/animation/manage_sprite.md | 9 +++-- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs b/MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs index 0ca9c84de..ff99c27c1 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 ────────────────────────────────────────────────── @@ -172,9 +174,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 +202,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; } } } @@ -219,13 +225,13 @@ 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 (existingState == state) continue; - var tr = existingState.AddTransition(state); - tr.AddCondition(AnimatorConditionMode.If, 0, trigger); - tr.hasExitTime = false; - } + 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 @@ -237,6 +243,7 @@ internal static (string path, int stateCount) BuildController( exitTr.hasExitTime = true; exitTr.exitTime = 1f; exitTr.hasFixedDuration = false; + exitTr.duration = 0f; } } @@ -248,6 +255,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/Server/src/services/tools/manage_sprite.py b/Server/src/services/tools/manage_sprite.py index 6acf3732d..345f37659 100644 --- a/Server/src/services/tools/manage_sprite.py +++ b/Server/src/services/tools/manage_sprite.py @@ -44,7 +44,7 @@ def _sprite_image_result(result: dict[str, Any], image_base64: str) -> ToolResul "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). " + "combat trigger states that fire from any state, simple state for single animations). " "full_setup: one command — slice → clips → controller." ), annotations=ToolAnnotations( diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs index f7e38ab40..67e1b980a 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs @@ -1239,6 +1239,41 @@ public void SetupController_OneShotsWithoutALoopingState_DoNotExitIntoEachOther( $"'{state.name}' got an exit-time transition"); } + [Test] + public void SetupController_TriggersFireFromAnyStateWithoutBlending_AndAnUnknownNameWarns() + { + // 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", "taunt")); + 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')"); + + 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"); + + var unreachable = result["diagnostics"] + .Where(d => d.Value("code") == "STATE_UNREACHABLE") + .Select(d => d.Value("message")) + .ToArray(); + Assert.AreEqual(1, unreachable.Length, + "'taunt' matches no action word, so no transition leads to its state and the response " + + "must say so; diagnostics were " + result["diagnostics"]); + Assert.That(unreachable[0], Does.Contain("'taunt'"), "the warning must name the clip it is about"); + } + [Test] public void SetupController_WalkAndRun_BuildsASpeedDrivenBlendTree() { diff --git a/website/docs/reference/tools/animation/manage_sprite.md b/website/docs/reference/tools/animation/manage_sprite.md index a522c9c3f..85763976f 100644 --- a/website/docs/reference/tools/animation/manage_sprite.md +++ b/website/docs/reference/tools/animation/manage_sprite.md @@ -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. +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, combat trigger states that fire from any state, simple state for single animations). full_setup: one command — slice → clips → controller. ## Parameters @@ -84,10 +84,15 @@ 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. +`run` collapse into a `Speed`-driven 1D blend tree, and `attack` gets an `Attack` trigger +that fires from any state. Every transition is instant, since sprite frames cannot blend. Looping follows from the same names — locomotion and idle loop, a one-shot does not — and an explicit `"loop"` on a clip overrides that. +A clip whose name holds no action word (such as idle, walk, run, jump, attack or hurt) gets +a state that no transition leads to. Unless it is the default state, it plays only from a +script, and the response warns `STATE_UNREACHABLE`. + ### Slicing on its own ```json From 23f2c3b043750f50a419c38a0b3454cd6c1589f2 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:47:04 -0400 Subject: [PATCH 2/7] fix(sprite): warn when a re-slice removes frames that clips may use A sprite's ID follows its name, so re-slicing a sheet keeps only the frames whose names the new grid reuses. A smaller grid deleted the rest, and every AnimationClip that played them lost those frames with nothing said: measured on 2021.3.45f2, an eight-frame clip of a 4x2 sheet had six frames missing after a 2x1 re-slice, and the response's diagnostics list was empty. slice_sheet (and full_setup's slice step) now compares the sheet's previous frame names, taken from the importer snapshot, with the new grid's and adds a SLICE_REMOVED_FRAMES warning that gives the count and names the removed frames (the first ten, then "and N more"). Its fix options say how to recover: slicing again with the previous grid brings the frames back - measured, the same clip found all eight again - or the clips can be rebuilt with overwrite=true. A first slice, or the same grid again, removes nothing and stays silent. The check runs after the generation check, because a refused slice restores the old frames and the warning would then be false. --- .../Tools/Sprite2D/SpriteImportSetup.cs | 21 +++++++++++++++++++ .../Tests/EditMode/Tools/ManageSpriteTests.cs | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs b/MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs index 7994573f5..9663337d8 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; @@ -380,6 +383,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/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs index 67e1b980a..b6d1c0784 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs @@ -810,7 +810,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 +818,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"); } // ===================================================================== From 8319da4714e77e95eee379dfe211a26caca79dda Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:47:10 -0400 Subject: [PATCH 3/7] feat(sprite): add filter_mode to slice_sheet and full_setup Slicing always set the texture filter to Point. That suits pixel art, the tool's main use, but nothing could turn it off, so a high-resolution sheet came out blocky with no way to ask for anything else. filter_mode takes point, bilinear or trilinear (case-insensitive on the C# side) and defaults to point, so a caller that leaves it out sees no change. It is read and validated with the grid values, before the importer is touched: an unknown value such as "nearest" is refused with BAD_PARAM and leaves the texture as it was. full_setup hands its params to the slice step, so it takes the same parameter. Exposed on the MCP tool and as --filter-mode on the CLI's slice and full-setup commands; documented in the generated tool reference, its slicing example, and both CLI guides. --- .../Tools/Sprite2D/SpriteImportSetup.cs | 24 ++++++++++++++++--- Server/src/cli/CLI_USAGE_GUIDE.md | 3 +++ Server/src/cli/commands/sprite.py | 12 ++++++---- Server/src/services/tools/manage_sprite.py | 7 +++++- Server/tests/test_manage_sprite.py | 9 +++---- .../Tests/EditMode/Tools/ManageSpriteTests.cs | 21 ++++++++++++++++ website/docs/guides/cli.md | 1 + .../tools/animation/manage_sprite.md | 3 +++ 8 files changed, 68 insertions(+), 12 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs b/MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs index 9663337d8..788857848 100644 --- a/MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs +++ b/MCPForUnity/Editor/Tools/Sprite2D/SpriteImportSetup.cs @@ -237,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 @@ -260,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 { @@ -273,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) @@ -360,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); 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..e76b629b4 100644 --- a/Server/src/cli/commands/sprite.py +++ b/Server/src/cli/commands/sprite.py @@ -58,9 +58,11 @@ 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. \b @@ -71,7 +73,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,6 +127,8 @@ 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.") @@ -137,7 +141,7 @@ def setup_controller(controller_path: str, clips: str, overwrite: bool): 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 @@ -150,7 +154,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/tools/manage_sprite.py b/Server/src/services/tools/manage_sprite.py index 345f37659..2a3244c1c 100644 --- a/Server/src/services/tools/manage_sprite.py +++ b/Server/src/services/tools/manage_sprite.py @@ -79,6 +79,11 @@ 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. " + "Default: point, which keeps pixel art sharp; bilinear or trilinear suits high-resolution art.", + ] = None, clips: Annotated[ list[dict[str, Any]] | None, "Clip definitions: [{name, start_frame, end_frame, fps (default 12), loop (auto-detect if omitted)}]. " @@ -151,7 +156,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 b6d1c0784..162c4561d 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))] diff --git a/website/docs/guides/cli.md b/website/docs/guides/cli.md index 46738a7bb..00916c214 100644 --- a/website/docs/guides/cli.md +++ b/website/docs/guides/cli.md @@ -476,6 +476,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 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 diff --git a/website/docs/reference/tools/animation/manage_sprite.md b/website/docs/reference/tools/animation/manage_sprite.md index 85763976f..565e44962 100644 --- a/website/docs/reference/tools/animation/manage_sprite.md +++ b/website/docs/reference/tools/animation/manage_sprite.md @@ -25,6 +25,7 @@ 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). | +| `filter_mode` | `Literal['point', 'bilinear', 'trilinear'] \| None` | — | slice_sheet and full_setup: texture filter the sliced sheet is imported with. Default: point, which keeps pixel art sharp; bilinear or trilinear suits high-resolution art. | | `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). | @@ -102,6 +103,8 @@ script, and the response warns `STATE_UNREACHABLE`. `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. +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 From e2f4d31e7802c02b8ded7b711ae2981ae6dc02a1 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:58:04 -0400 Subject: [PATCH 4/7] docs(sprite): document clip-name rules, re-slice and overwrite semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manage_sprite description: lead with a summary sentence, so the tool catalog no longer cuts it at "setup_clips: cre…"; state the clip-name rules, including trigger states that fire from any state - Parameter docs: filter_mode casing and its reset on every slice; clips, animation_name, output_dir and controller_path defaults; overwrite (CLIP_EXISTS, CONTROLLER_EXISTS, slicing not covered); add_to_scene and scene_target; next_cursor is null on the last page - Reference examples: clip-name table (any-state triggers, instant transitions, STATE_UNREACHABLE), get_info image limits, re-run and SLICE_REMOVED_FRAMES, reading diagnostics - CLI help: slice side effects, full-setup defaults and overwrite scope; the coin example passes "loop": true, since "spin" alone plays once - animation group blurb mentions 2D sprite sheets; tool count is 50; Animation row in both SKILL.md copies --- .claude/skills/unity-mcp-skill/SKILL.md | 1 + README.md | 2 +- Server/src/cli/commands/sprite.py | 26 +++++-- Server/src/services/registry/tool_registry.py | 2 +- Server/src/services/tools/manage_sprite.py | 53 ++++++++----- docs/i18n/README-zh.md | 2 +- unity-mcp-skill/SKILL.md | 1 + website/docs/guides/cli-examples.md | 2 +- website/docs/guides/cli.md | 2 +- website/docs/guides/tool-groups.md | 8 +- .../docs/reference/tools/animation/index.md | 4 +- .../tools/animation/manage_sprite.md | 78 +++++++++++++------ website/docs/reference/tools/index.md | 4 +- 13 files changed, 123 insertions(+), 62 deletions(-) diff --git a/.claude/skills/unity-mcp-skill/SKILL.md b/.claude/skills/unity-mcp-skill/SKILL.md index 1c63b4de1..c8d1fa287 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 the 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/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/commands/sprite.py b/Server/src/cli/commands/sprite.py index e76b629b4..d0a645c8a 100644 --- a/Server/src/cli/commands/sprite.py +++ b/Server/src/cli/commands/sprite.py @@ -65,6 +65,9 @@ def slice_sheet(path: str, cols: Optional[int], rows: Optional[int], frame_width 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 @@ -131,12 +134,20 @@ def setup_controller(controller_path: str, clips: str, overwrite: bool): 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], @@ -146,7 +157,8 @@ def full_setup(path: str, cols: Optional[int], rows: Optional[int], frame_width: \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 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 2a3244c1c..a32e6f64b 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, " - "combat trigger states that fire from any state, 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, walk/run = " + "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", @@ -81,35 +82,49 @@ async def manage_sprite( ] = None, filter_mode: Annotated[ Literal["point", "bilinear", "trilinear"] | None, - "slice_sheet and full_setup: texture filter the sliced sheet is imported with. " - "Default: point, which keeps pixel art sharp; bilinear or trilinear suits high-resolution art.", + "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 @@ -123,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: 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..6fd6f9bb7 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 the 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 00916c214..168976d0e 100644 --- a/website/docs/guides/cli.md +++ b/website/docs/guides/cli.md @@ -479,7 +479,7 @@ unity-mcp sprite slice "Assets/Sprites/Hero.png" --cols 6 --rows 4 # Or --fram 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 565e44962..748e44991 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, combat trigger states that fire from any state, 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, walk/run = 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,16 +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). | -| `filter_mode` | `Literal['point', 'bilinear', 'trilinear'] \| None` | — | slice_sheet and full_setup: texture filter the sliced sheet is imported with. Default: point, which keeps pixel art sharp; bilinear or trilinear suits high-resolution art. | -| `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 @@ -53,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. @@ -84,15 +89,21 @@ 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 -that fires from any state. Every transition is instant, since sprite frames cannot blend. -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 gets a state. | 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. | 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 | -A clip whose name holds no action word (such as idle, walk, run, jump, attack or hurt) gets -a state that no transition leads to. Unless it is the default state, it plays only from a -script, and the response warns `STATE_UNREACHABLE`. +Every transition is instant, since sprite frames cannot blend. Clips that share a word share +its trigger, so give each one-shot its own action word. An explicit `"loop"` on a clip +overrides the default. ### Slicing on its own @@ -102,18 +113,39 @@ script, and the response warns `STATE_UNREACHABLE`. `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`, so repeating a `full_setup` ends with `success: false` at +`step: "setup_controller"` (`No valid clips loaded.` when every clip already existed): ```json { "action": "setup_clips", "path": "Assets/Sprites/hero.png", "clips": [{ "name": "walk", "start_frame": 0, "end_frame": 5 }], "overwrite": true } ``` + +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) From 0917666f643a74e46c9fb8235ec7addd3e8ec843 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:04:13 -0400 Subject: [PATCH 5/7] fix(sprite): warn on unplayed idle/trigger clips, hold death frames setup_controller (and full_setup, which calls it) left clips unplayable without saying so, and stood dead characters back up. The controller has one Idle state, built from the first idle-type clip; every later one got no state, so idle + idle_blink lost idle_blink silently. Each extra idle clip now gets an IDLE_CLIP_UNUSED warning that names it and the clip the Idle state plays. Its fixes: rename it to include an action word and neither idle nor stand, or put it in its own controller. Clips that share an action word share its trigger: attack + hero_attack both got an Any State transition on Attack. The conditions were the same, so every firing resolved to the first transition and hero_attack could never play. The first clip on a trigger now owns it. A later one keeps its state, and its one-shot exit for a script that plays it, but gets no Any State transition, since that transition could never fire. A TRIGGER_SHARED warning names both clips and the trigger, says which one plays, and asks for an action word per clip. die/death one-shots returned to Idle when they ended. SpriteNamingDetector now marks a clip whose name has the word die or death as Terminal, whichever word picks its trigger, and the builder gives a terminal one-shot no exit, so its state holds the last frame. Triggers still fire from Any State, so one set after the death leaves it; the reference page says so. Tests: the STATE_UNREACHABLE check moves out of the Any State test into one parametrized test with a case per warning, which also asserts that no transition leads to the clip. The one-shot exit test gains die and hero_death cases. Each new case failed before this change. --- .../Tools/Sprite2D/SpriteControllerBuilder.cs | 45 ++++++++++---- .../Tools/Sprite2D/SpriteNamingDetector.cs | 5 ++ .../Tests/EditMode/Tools/ManageSpriteTests.cs | 58 +++++++++++++------ .../tools/animation/manage_sprite.md | 13 +++-- 4 files changed, 87 insertions(+), 34 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs b/MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs index ff99c27c1..65f10c4d3 100644 --- a/MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs +++ b/MCPForUnity/Editor/Tools/Sprite2D/SpriteControllerBuilder.cs @@ -149,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 ──────────────────────────────────────────────────── @@ -218,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); @@ -225,19 +235,30 @@ internal static (string path, int stateCount) BuildController( string trigger = pair.entry.TriggerName ?? 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; + if (triggerOwners.TryGetValue(trigger, out string owner)) + { + 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; 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/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs index 162c4561d..d7718c589 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs @@ -1266,11 +1266,11 @@ public void SetupController_OneShotsWithoutALoopingState_DoNotExitIntoEachOther( } [Test] - public void SetupController_TriggersFireFromAnyStateWithoutBlending_AndAnUnknownNameWarns() + 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", "taunt")); + 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; @@ -1289,15 +1289,36 @@ public void SetupController_TriggersFireFromAnyStateWithoutBlending_AndAnUnknown .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()); - var unreachable = result["diagnostics"] - .Where(d => d.Value("code") == "STATE_UNREACHABLE") + // 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, unreachable.Length, - "'taunt' matches no action word, so no transition leads to its state and the response " + - "must say so; diagnostics were " + result["diagnostics"]); - Assert.That(unreachable[0], Does.Contain("'taunt'"), "the warning must name the clip it is about"); + Assert.AreEqual(1, warnings.Length, "diagnostics were " + result["diagnostics"]); + foreach (string name in named) + Assert.That(warnings[0], Does.Contain(name)); } [Test] @@ -1605,28 +1626,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/website/docs/reference/tools/animation/manage_sprite.md b/website/docs/reference/tools/animation/manage_sprite.md index 748e44991..62ec6bb1e 100644 --- a/website/docs/reference/tools/animation/manage_sprite.md +++ b/website/docs/reference/tools/animation/manage_sprite.md @@ -96,14 +96,17 @@ split into words (on `_`, `-`, spaces, camelCase and digits, so `heroAttack2` re | Words in the clip name | State | Loops by default | |---|---|---| -| `idle`, `stand` | `Idle`, the default state. Only the first such clip gets a state. | yes | +| `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. | no | +| `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. Clips that share a word share -its trigger, so give each one-shot its own action word. An explicit `"loop"` on a clip -overrides the default. +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 From 932dbcff5efda44275a5afe908c62a1e6338d11b Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:06:34 -0400 Subject: [PATCH 6/7] fix(sprite): stop a repeated full_setup at setup_clips A second full_setup without overwrite skipped every clip with CLIP_EXISTS, then failed at step setup_controller with "No valid clips loaded.", which named neither the cause nor the way past it. When every requested clip already exists, full_setup now stops at step setup_clips with ALL_CLIPS_EXIST. Its fixes: overwrite=true, or setup_controller with the existing .anim paths, which the CLIP_EXISTS warnings name (plus overwrite=true there if the controller already exists). A re-run that writes at least one new clip still reaches the controller step and stops there with CONTROLLER_EXISTS, as before. FullSetup_ControllerRefusal_StopsBeforeTouchingTheScene passed through the NO_CLIPS path, not the controller refusal it was named for. It is now a two-case test: the same clip again (ALL_CLIPS_EXIST at setup_clips, which failed before this change) and a new clip (CONTROLLER_EXISTS at setup_controller). In both, the scene is left untouched. --- .../Editor/Tools/Sprite2D/SpriteFullSetup.cs | 7 +++++ .../Tests/EditMode/Tools/ManageSpriteTests.cs | 31 +++++++++++++------ .../tools/animation/manage_sprite.md | 10 ++++-- 3 files changed, 36 insertions(+), 12 deletions(-) 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/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs index d7718c589..cfba20690 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs @@ -1456,8 +1456,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"); @@ -1467,17 +1472,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); } } diff --git a/website/docs/reference/tools/animation/manage_sprite.md b/website/docs/reference/tools/animation/manage_sprite.md index 62ec6bb1e..83d6d3b05 100644 --- a/website/docs/reference/tools/animation/manage_sprite.md +++ b/website/docs/reference/tools/animation/manage_sprite.md @@ -125,14 +125,20 @@ The sheet is imported with point filtering, which keeps pixel art sharp; pass `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`, so repeating a `full_setup` ends with `success: false` at -`step: "setup_controller"` (`No valid clips loaded.` when every clip already existed): +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 From d942512268cc45c23a77e5702af810837cffc5d7 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:39:35 -0400 Subject: [PATCH 7/7] fix(sprite): address review: pin canTransitionToSelf, qualify the image and locomotion wording - The Any State test now also fails if a generated trigger transition has canTransitionToSelf off, which would leave a repeated trigger set and replay its state later (Copilot). - Both skill copies say get_info returns the sheet as an image only for a PNG or JPEG source (CodeRabbit). - The tool description says one walk/run clip becomes a plain state and two or more a Speed-driven blend tree; reference page regenerated (CodeRabbit). --- .claude/skills/unity-mcp-skill/SKILL.md | 2 +- Server/src/services/tools/manage_sprite.py | 6 +++--- .../Assets/Tests/EditMode/Tools/ManageSpriteTests.cs | 3 +++ unity-mcp-skill/SKILL.md | 2 +- website/docs/reference/tools/animation/manage_sprite.md | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.claude/skills/unity-mcp-skill/SKILL.md b/.claude/skills/unity-mcp-skill/SKILL.md index c8d1fa287..7c41a7fda 100644 --- a/.claude/skills/unity-mcp-skill/SKILL.md +++ b/.claude/skills/unity-mcp-skill/SKILL.md @@ -191,7 +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 the 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")`. | +| **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/Server/src/services/tools/manage_sprite.py b/Server/src/services/tools/manage_sprite.py index a32e6f64b..8044b7760 100644 --- a/Server/src/services/tools/manage_sprite.py +++ b/Server/src/services/tools/manage_sprite.py @@ -43,9 +43,9 @@ def _sprite_image_result(result: dict[str, Any], image_base64: str) -> ToolResul "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, walk/run = " - "Speed-driven 1D blend tree, jump/attack/hurt-type names = trigger states that fire from any " - "state, other names = plain states); " + "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( diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs index cfba20690..077feecb5 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSpriteTests.cs @@ -1280,6 +1280,9 @@ public void SetupController_TriggersFireFromAnyStateWithoutBlending() 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))) diff --git a/unity-mcp-skill/SKILL.md b/unity-mcp-skill/SKILL.md index 6fd6f9bb7..743d47feb 100644 --- a/unity-mcp-skill/SKILL.md +++ b/unity-mcp-skill/SKILL.md @@ -191,7 +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 the 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")`. | +| **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/reference/tools/animation/manage_sprite.md b/website/docs/reference/tools/animation/manage_sprite.md index 83d6d3b05..78c67335e 100644 --- a/website/docs/reference/tools/animation/manage_sprite.md +++ b/website/docs/reference/tools/animation/manage_sprite.md @@ -12,7 +12,7 @@ description: "Slice 2D sprite sheets and build AnimationClips and an AnimatorCon ## Description -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, walk/run = 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. +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