using System; using System.Collections.Generic; using System.IO; using System.Linq; using MCPForUnity.Editor.Helpers; // For Response class using MCPForUnity.Runtime.Helpers; // For ScreenshotUtility using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; namespace MCPForUnity.Editor.Tools { /// /// Handles scene management operations like loading, saving, creating, and querying hierarchy. /// [McpForUnityTool("manage_scene", AutoRegister = false)] public static class ManageScene { private sealed class SceneCommand { public string action { get; set; } = string.Empty; public string name { get; set; } = string.Empty; public string path { get; set; } = string.Empty; public int? buildIndex { get; set; } public string fileName { get; set; } = string.Empty; public int? superSize { get; set; } // get_hierarchy paging + safety (summary-first) public JToken parent { get; set; } public int? pageSize { get; set; } public int? cursor { get; set; } public int? maxNodes { get; set; } public int? maxDepth { get; set; } public int? maxChildrenPerNode { get; set; } public bool? includeTransform { get; set; } } private static SceneCommand ToSceneCommand(JObject p) { if (p == null) return new SceneCommand(); return new SceneCommand { action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), name = p["name"]?.ToString() ?? string.Empty, path = p["path"]?.ToString() ?? string.Empty, buildIndex = ParamCoercion.CoerceIntNullable(p["buildIndex"] ?? p["build_index"]), fileName = (p["fileName"] ?? p["filename"])?.ToString() ?? string.Empty, superSize = ParamCoercion.CoerceIntNullable(p["superSize"] ?? p["super_size"] ?? p["supersize"]), // get_hierarchy paging + safety parent = p["parent"], pageSize = ParamCoercion.CoerceIntNullable(p["pageSize"] ?? p["page_size"]), cursor = ParamCoercion.CoerceIntNullable(p["cursor"]), maxNodes = ParamCoercion.CoerceIntNullable(p["maxNodes"] ?? p["max_nodes"]), maxDepth = ParamCoercion.CoerceIntNullable(p["maxDepth"] ?? p["max_depth"]), maxChildrenPerNode = ParamCoercion.CoerceIntNullable(p["maxChildrenPerNode"] ?? p["max_children_per_node"]), includeTransform = ParamCoercion.CoerceBoolNullable(p["includeTransform"] ?? p["include_transform"]), }; } /// /// Main handler for scene management actions. /// public static object HandleCommand(JObject @params) { try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { } var cmd = ToSceneCommand(@params); string action = cmd.action; string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name; string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/ int? buildIndex = cmd.buildIndex; // bool loadAdditive = @params["loadAdditive"]?.ToObject() ?? false; // Example for future extension // Ensure path is relative to Assets/, removing any leading "Assets/" string relativeDir = path ?? string.Empty; if (!string.IsNullOrEmpty(relativeDir)) { relativeDir = AssetPathUtility.NormalizeSeparators(relativeDir).Trim('/'); if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); } } // Apply default *after* sanitizing, using the original path variable for the check if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness { relativeDir = "Scenes"; // Default relative directory } if (string.IsNullOrEmpty(action)) { return new ErrorResponse("Action parameter is required."); } string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; // Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets) string fullPath = string.IsNullOrEmpty(sceneFileName) ? null : Path.Combine(fullPathDir, sceneFileName); // Ensure relativePath always starts with "Assets/" and uses forward slashes string relativePath = string.IsNullOrEmpty(sceneFileName) ? null : AssetPathUtility.NormalizeSeparators(Path.Combine("Assets", relativeDir, sceneFileName)); // Ensure directory exists for 'create' if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) { try { Directory.CreateDirectory(fullPathDir); } catch (Exception e) { return new ErrorResponse( $"Could not create directory '{fullPathDir}': {e.Message}" ); } } // Route action try { McpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { } switch (action) { case "create": if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) return new ErrorResponse( "'name' and 'path' parameters are required for 'create' action." ); return CreateScene(fullPath, relativePath); case "load": // Loading can be done by path/name or build index if (!string.IsNullOrEmpty(relativePath)) return LoadScene(relativePath); else if (buildIndex.HasValue) return LoadScene(buildIndex.Value); else return new ErrorResponse( "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." ); case "save": // Save current scene, optionally to a new path return SaveScene(fullPath, relativePath); case "get_hierarchy": try { McpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { } var gh = GetSceneHierarchyPaged(cmd); try { McpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { } return gh; case "get_active": try { McpLog.Info("[ManageScene] get_active: entering", always: false); } catch { } var ga = GetActiveSceneInfo(); try { McpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { } return ga; case "get_build_settings": return GetBuildSettingsScenes(); case "screenshot": return CaptureScreenshot(cmd.fileName, cmd.superSize); // Add cases for modifying build settings, additive loading, unloading etc. default: return new ErrorResponse( $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings, screenshot." ); } } /// /// Captures a screenshot to Assets/Screenshots and returns a response payload. /// Public so the tools UI can reuse the same logic without duplicating parameters. /// Available in both Edit Mode and Play Mode. /// public static object ExecuteScreenshot(string fileName = null, int? superSize = null) { return CaptureScreenshot(fileName, superSize); } private static object CreateScene(string fullPath, string relativePath) { if (File.Exists(fullPath)) { return new ErrorResponse($"Scene already exists at '{relativePath}'."); } try { // Create a new empty scene Scene newScene = EditorSceneManager.NewScene( NewSceneSetup.EmptyScene, NewSceneMode.Single ); // Save it to the specified path bool saved = EditorSceneManager.SaveScene(newScene, relativePath); if (saved) { AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Ensure Unity sees the new scene file return new SuccessResponse( $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", new { path = relativePath } ); } else { // If SaveScene fails, it might leave an untitled scene open. // Optionally try to close it, but be cautious. return new ErrorResponse($"Failed to save new scene to '{relativePath}'."); } } catch (Exception e) { return new ErrorResponse($"Error creating scene '{relativePath}': {e.Message}"); } } private static object LoadScene(string relativePath) { if ( !File.Exists( Path.Combine( Application.dataPath.Substring( 0, Application.dataPath.Length - "Assets".Length ), relativePath ) ) ) { return new ErrorResponse($"Scene file not found at '{relativePath}'."); } // Check for unsaved changes in the current scene if (EditorSceneManager.GetActiveScene().isDirty) { // Optionally prompt the user or save automatically before loading return new ErrorResponse( "Current scene has unsaved changes. Please save or discard changes before loading a new scene." ); // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); // if (!saveOK) return new ErrorResponse("Load cancelled by user."); } try { EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); return new SuccessResponse( $"Scene '{relativePath}' loaded successfully.", new { path = relativePath, name = Path.GetFileNameWithoutExtension(relativePath), } ); } catch (Exception e) { return new ErrorResponse($"Error loading scene '{relativePath}': {e.Message}"); } } private static object LoadScene(int buildIndex) { if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) { return new ErrorResponse( $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." ); } // Check for unsaved changes if (EditorSceneManager.GetActiveScene().isDirty) { return new ErrorResponse( "Current scene has unsaved changes. Please save or discard changes before loading a new scene." ); } try { string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); return new SuccessResponse( $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", new { path = scenePath, name = Path.GetFileNameWithoutExtension(scenePath), buildIndex = buildIndex, } ); } catch (Exception e) { return new ErrorResponse( $"Error loading scene with build index {buildIndex}: {e.Message}" ); } } private static object SaveScene(string fullPath, string relativePath) { try { Scene currentScene = EditorSceneManager.GetActiveScene(); if (!currentScene.IsValid()) { return new ErrorResponse("No valid scene is currently active to save."); } bool saved; string finalPath = currentScene.path; // Path where it was last saved or will be saved if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath) { // Save As... // Ensure directory exists string dir = Path.GetDirectoryName(fullPath); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); saved = EditorSceneManager.SaveScene(currentScene, relativePath); finalPath = relativePath; } else { // Save (overwrite existing or save untitled) if (string.IsNullOrEmpty(currentScene.path)) { // Scene is untitled, needs a path return new ErrorResponse( "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." ); } saved = EditorSceneManager.SaveScene(currentScene); } if (saved) { AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); return new SuccessResponse( $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", new { path = finalPath, name = currentScene.name } ); } else { return new ErrorResponse($"Failed to save scene '{currentScene.name}'."); } } catch (Exception e) { return new ErrorResponse($"Error saving scene: {e.Message}"); } } private static object CaptureScreenshot(string fileName, int? superSize) { try { int resolvedSuperSize = (superSize.HasValue && superSize.Value > 0) ? superSize.Value : 1; // Batch mode warning if (Application.isBatchMode) { McpLog.Warn("[ManageScene] Screenshot capture in batch mode uses camera-based fallback. Results may vary."); } // Check Screen Capture module availability and warn if not available bool screenCaptureAvailable = ScreenshotUtility.IsScreenCaptureModuleAvailable; bool hasCameraFallback = Camera.main != null || UnityEngine.Object.FindObjectsOfType().Length > 0; #if UNITY_2022_1_OR_NEWER if (!screenCaptureAvailable && !hasCameraFallback) { return new ErrorResponse( "Cannot capture screenshot. The Screen Capture module is not enabled and no Camera was found in the scene. " + "Please either: (1) Enable the Screen Capture module: Window > Package Manager > Built-in > Screen Capture > Enable, " + "or (2) Add a Camera to your scene for camera-based fallback capture." ); } if (!screenCaptureAvailable) { McpLog.Warn("[ManageScene] Screen Capture module not enabled. Using camera-based fallback. " + "For best results, enable it: Window > Package Manager > Built-in > Screen Capture > Enable."); } #else if (!hasCameraFallback) { return new ErrorResponse( "No camera found in the scene. Screenshot capture on Unity versions before 2022.1 requires a Camera in the scene. " + "Please add a Camera to your scene or upgrade to Unity 2022.1+ for ScreenCapture API support." ); } #endif // Best-effort: ensure Game View exists and repaints before capture. if (!Application.isBatchMode) { EnsureGameView(); } ScreenshotCaptureResult result = ScreenshotUtility.CaptureToAssetsFolder(fileName, resolvedSuperSize, ensureUniqueFileName: true); // ScreenCapture.CaptureScreenshot is async. Import after the file actually hits disk. if (result.IsAsync) { ScheduleAssetImportWhenFileExists(result.AssetsRelativePath, result.FullPath, timeoutSeconds: 30.0); } else { AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); } string verb = result.IsAsync ? "Screenshot requested" : "Screenshot captured"; string message = $"{verb} to '{result.AssetsRelativePath}' (full: {result.FullPath})."; return new SuccessResponse( message, new { path = result.AssetsRelativePath, fullPath = result.FullPath, superSize = result.SuperSize, isAsync = result.IsAsync, } ); } catch (Exception e) { return new ErrorResponse($"Error capturing screenshot: {e.Message}"); } } private static void EnsureGameView() { try { // Ensure a Game View exists and has a chance to repaint before capture. try { if (!EditorApplication.ExecuteMenuItem("Window/General/Game")) { // Some Unity versions expose hotkey suffixes in menu paths. EditorApplication.ExecuteMenuItem("Window/General/Game %2"); } } catch (Exception e) { try { McpLog.Debug($"[ManageScene] screenshot: failed to open Game View via menu item: {e.Message}"); } catch { } } try { var gameViewType = Type.GetType("UnityEditor.GameView,UnityEditor"); if (gameViewType != null) { var window = EditorWindow.GetWindow(gameViewType); window?.Repaint(); } } catch (Exception e) { try { McpLog.Debug($"[ManageScene] screenshot: failed to repaint Game View: {e.Message}"); } catch { } } try { SceneView.RepaintAll(); } catch (Exception e) { try { McpLog.Debug($"[ManageScene] screenshot: failed to repaint Scene View: {e.Message}"); } catch { } } try { EditorApplication.QueuePlayerLoopUpdate(); } catch (Exception e) { try { McpLog.Debug($"[ManageScene] screenshot: failed to queue player loop update: {e.Message}"); } catch { } } } catch (Exception e) { try { McpLog.Debug($"[ManageScene] screenshot: EnsureGameView failed: {e.Message}"); } catch { } } } private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath, string fullPath, double timeoutSeconds) { if (string.IsNullOrWhiteSpace(assetsRelativePath) || string.IsNullOrWhiteSpace(fullPath)) { McpLog.Warn("[ManageScene] ScheduleAssetImportWhenFileExists: invalid paths provided, skipping import scheduling."); return; } double start = EditorApplication.timeSinceStartup; int failureCount = 0; bool hasSeenFile = false; const int maxLoggedFailures = 3; EditorApplication.CallbackFunction tick = null; tick = () => { try { if (File.Exists(fullPath)) { hasSeenFile = true; AssetDatabase.ImportAsset(assetsRelativePath, ImportAssetOptions.ForceSynchronousImport); McpLog.Debug($"[ManageScene] Imported asset at '{assetsRelativePath}'."); EditorApplication.update -= tick; return; } } catch (Exception e) { failureCount++; if (failureCount <= maxLoggedFailures) { McpLog.Warn($"[ManageScene] Exception while importing asset '{assetsRelativePath}' from '{fullPath}' (attempt {failureCount}): {e}"); } } if (EditorApplication.timeSinceStartup - start > timeoutSeconds) { if (!hasSeenFile) { McpLog.Warn($"[ManageScene] Timed out waiting for file '{fullPath}' (asset: '{assetsRelativePath}') after {timeoutSeconds:F1} seconds. The asset was not imported."); } else { McpLog.Warn($"[ManageScene] Timed out importing asset '{assetsRelativePath}' from '{fullPath}' after {timeoutSeconds:F1} seconds. The file existed but the asset was not imported."); } EditorApplication.update -= tick; } }; EditorApplication.update += tick; } private static object GetActiveSceneInfo() { try { try { McpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { } Scene activeScene = EditorSceneManager.GetActiveScene(); try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } if (!activeScene.IsValid()) { return new ErrorResponse("No active scene found."); } var sceneInfo = new { name = activeScene.name, path = activeScene.path, buildIndex = activeScene.buildIndex, // -1 if not in build settings isDirty = activeScene.isDirty, isLoaded = activeScene.isLoaded, rootCount = activeScene.rootCount, }; return new SuccessResponse("Retrieved active scene information.", sceneInfo); } catch (Exception e) { try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { } return new ErrorResponse($"Error getting active scene info: {e.Message}"); } } private static object GetBuildSettingsScenes() { try { var scenes = new List(); for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) { var scene = EditorBuildSettings.scenes[i]; scenes.Add( new { path = scene.path, guid = scene.guid.ToString(), enabled = scene.enabled, buildIndex = i, // Actual build index considering only enabled scenes might differ } ); } return new SuccessResponse("Retrieved scenes from Build Settings.", scenes); } catch (Exception e) { return new ErrorResponse($"Error getting scenes from Build Settings: {e.Message}"); } } private static object GetSceneHierarchyPaged(SceneCommand cmd) { try { // Check Prefab Stage first var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); Scene activeScene; if (prefabStage != null) { activeScene = prefabStage.scene; try { McpLog.Info("[ManageScene] get_hierarchy: using Prefab Stage scene", always: false); } catch { } } else { try { McpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { } activeScene = EditorSceneManager.GetActiveScene(); } try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } if (!activeScene.IsValid() || !activeScene.isLoaded) { return new ErrorResponse( "No valid and loaded scene is active to get hierarchy from." ); } // Defaults tuned for safety; callers can override but we clamp to sane maxes. // NOTE: pageSize is "items per page", not "number of pages". // Keep this conservative to reduce peak response sizes when callers omit page_size. int resolvedPageSize = Mathf.Clamp(cmd.pageSize ?? 50, 1, 500); int resolvedCursor = Mathf.Max(0, cmd.cursor ?? 0); int resolvedMaxNodes = Mathf.Clamp(cmd.maxNodes ?? 1000, 1, 5000); int effectiveTake = Mathf.Min(resolvedPageSize, resolvedMaxNodes); int resolvedMaxChildrenPerNode = Mathf.Clamp(cmd.maxChildrenPerNode ?? 200, 0, 2000); bool includeTransform = cmd.includeTransform ?? false; // NOTE: maxDepth is accepted for forward-compatibility, but current paging mode // returns a single level (roots or direct children). This keeps payloads bounded. List nodes; string scope; GameObject parentGo = ResolveGameObject(cmd.parent, activeScene); if (cmd.parent == null || cmd.parent.Type == JTokenType.Null) { try { McpLog.Info("[ManageScene] get_hierarchy: listing root objects (paged summary)", always: false); } catch { } nodes = activeScene.GetRootGameObjects().Where(go => go != null).ToList(); scope = "roots"; } else { if (parentGo == null) { return new ErrorResponse($"Parent GameObject ('{cmd.parent}') not found."); } try { McpLog.Info($"[ManageScene] get_hierarchy: listing children of '{parentGo.name}' (paged summary)", always: false); } catch { } nodes = new List(parentGo.transform.childCount); foreach (Transform child in parentGo.transform) { if (child != null) nodes.Add(child.gameObject); } scope = "children"; } int total = nodes.Count; if (resolvedCursor > total) resolvedCursor = total; int end = Mathf.Min(total, resolvedCursor + effectiveTake); var items = new List(Mathf.Max(0, end - resolvedCursor)); for (int i = resolvedCursor; i < end; i++) { var go = nodes[i]; if (go == null) continue; items.Add(BuildGameObjectSummary(go, includeTransform, resolvedMaxChildrenPerNode)); } bool truncated = end < total; string nextCursor = truncated ? end.ToString() : null; var payload = new { scope = scope, cursor = resolvedCursor, pageSize = effectiveTake, next_cursor = nextCursor, truncated = truncated, total = total, items = items, }; var resp = new SuccessResponse($"Retrieved hierarchy page for scene '{activeScene.name}'.", payload); try { McpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { } return resp; } catch (Exception e) { try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { } return new ErrorResponse($"Error getting scene hierarchy: {e.Message}"); } } private static GameObject ResolveGameObject(JToken targetToken, Scene activeScene) { if (targetToken == null || targetToken.Type == JTokenType.Null) return null; try { if (targetToken.Type == JTokenType.Integer || int.TryParse(targetToken.ToString(), out _)) { if (int.TryParse(targetToken.ToString(), out int id)) { var obj = EditorUtility.InstanceIDToObject(id); if (obj is GameObject go) return go; if (obj is Component c) return c.gameObject; } } } catch { } string s = targetToken.ToString(); if (string.IsNullOrEmpty(s)) return null; // Path-based find (e.g., "Root/Child/GrandChild") if (s.Contains("/")) { try { var ids = GameObjectLookup.SearchGameObjects("by_path", s, includeInactive: true, maxResults: 1); if (ids.Count > 0) { var byPath = GameObjectLookup.FindById(ids[0]); if (byPath != null) return byPath; } } catch { } } // Name-based find (first match, includes inactive) try { var all = activeScene.GetRootGameObjects(); foreach (var root in all) { if (root == null) continue; if (root.name == s) return root; var trs = root.GetComponentsInChildren(includeInactive: true); foreach (var t in trs) { if (t != null && t.gameObject != null && t.gameObject.name == s) return t.gameObject; } } } catch { } return null; } private static object BuildGameObjectSummary(GameObject go, bool includeTransform, int maxChildrenPerNode) { if (go == null) return null; int childCount = 0; try { childCount = go.transform != null ? go.transform.childCount : 0; } catch { } bool childrenTruncated = childCount > 0; // We do not inline children in summary mode. // Get component type names (lightweight - no full serialization) var componentTypes = new List(); try { var components = go.GetComponents(); if (components != null) { foreach (var c in components) { if (c != null) { componentTypes.Add(c.GetType().Name); } } } } catch (Exception ex) { McpLog.Debug($"[ManageScene] Failed to enumerate components for '{go.name}': {ex.Message}"); } var d = new Dictionary { { "name", go.name }, { "instanceID", go.GetInstanceID() }, { "activeSelf", go.activeSelf }, { "activeInHierarchy", go.activeInHierarchy }, { "tag", go.tag }, { "layer", go.layer }, { "isStatic", go.isStatic }, { "path", GetGameObjectPath(go) }, { "childCount", childCount }, { "childrenTruncated", childrenTruncated }, { "childrenCursor", childCount > 0 ? "0" : null }, { "childrenPageSizeDefault", maxChildrenPerNode }, { "componentTypes", componentTypes }, }; if (includeTransform && go.transform != null) { var t = go.transform; d["transform"] = new { position = new[] { t.localPosition.x, t.localPosition.y, t.localPosition.z }, rotation = new[] { t.localRotation.eulerAngles.x, t.localRotation.eulerAngles.y, t.localRotation.eulerAngles.z }, scale = new[] { t.localScale.x, t.localScale.y, t.localScale.z }, }; } return d; } private static string GetGameObjectPath(GameObject go) { if (go == null) return string.Empty; try { var names = new Stack(); Transform t = go.transform; while (t != null) { names.Push(t.name); t = t.parent; } return string.Join("/", names); } catch { return go.name; } } } }