Last active
December 9, 2025 07:15
-
-
Save supertask/86dcfc865e96d125d8cece1fa73148e9 to your computer and use it in GitHub Desktop.
Unity Hierarchy Web Server
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Assets/Editor/HierarchyWebServer.cs | |
| #if UNITY_EDITOR | |
| using System; | |
| using System.Net; | |
| using System.Text; | |
| using System.Threading; | |
| using System.Collections.Generic; | |
| using UnityEditor; | |
| using UnityEngine; | |
| public class HierarchyWebServer : ScriptableObject | |
| { | |
| // ====== 設定 ====== | |
| const string HOST = "http://localhost:8777/"; // ポート競合時は変更(末尾スラッシュ必須) | |
| const int MAX_DEPTH = 64; | |
| const bool DEBUG_MODE = false; // デバッグ時のみtrueに設定(頻繁なログ出力を制御) | |
| static HttpListener _listener; | |
| static Thread _thread; | |
| static volatile bool _running; | |
| // サーバスレッドから読むだけ(Unity APIは触らない) | |
| static volatile string _latestText = "waiting…"; | |
| static volatile string _latestJson = "{\"status\":\"waiting\"}"; | |
| static volatile string _latestSceneViewBase64 = ""; | |
| static volatile string _latestGameViewBase64 = ""; | |
| // ウィンドウからアクセス可能にするためのプロパティ | |
| public static string LatestText => _latestText; | |
| public static bool IsRunning => _running; | |
| public static void StartServer() | |
| { | |
| if (_running) { Debug.Log("[HierarchyWebServer] already running."); return; } | |
| // 選択変更イベントを登録(エディタの描画中に呼ばれない安全な方法) | |
| Selection.selectionChanged -= OnSelectionChanged; | |
| Selection.selectionChanged += OnSelectionChanged; | |
| // プレイモード変更時のクリーンアップを登録 | |
| EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; | |
| EditorApplication.playModeStateChanged += OnPlayModeStateChanged; | |
| try | |
| { | |
| // HOSTの検証 | |
| if (string.IsNullOrWhiteSpace(HOST)) | |
| { | |
| Debug.LogWarning("[HierarchyWebServer] HOST is not configured. Web server will not start."); | |
| Selection.selectionChanged -= OnSelectionChanged; | |
| EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; | |
| return; | |
| } | |
| _listener = new HttpListener(); | |
| // HttpListenerのPrefixは末尾にスラッシュが必要 | |
| string prefix = HOST.EndsWith("/") ? HOST : HOST + "/"; | |
| _listener.Prefixes.Add(prefix); | |
| _listener.Start(); | |
| _running = true; | |
| _thread = new Thread(HandleLoop) { IsBackground = true, Name = "HierarchyWebServer" }; | |
| _thread.Start(); | |
| // 初回スナップショット(エディタの描画が完了した後に実行) | |
| EditorApplication.delayCall += RebuildSnapshotFromSelection; | |
| Debug.Log($"[HierarchyWebServer] listening on {prefix}"); | |
| } | |
| catch (ArgumentException e) | |
| { | |
| // 無効なHOST設定の場合は静かに失敗(ブラウザで何も表示しない) | |
| _running = false; | |
| _listener = null; | |
| Selection.selectionChanged -= OnSelectionChanged; | |
| EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; | |
| Debug.LogWarning($"[HierarchyWebServer] Invalid HOST configuration: {HOST}. Web server will not start. ({e.Message})"); | |
| } | |
| catch (Exception e) | |
| { | |
| _running = false; | |
| _listener = null; | |
| Selection.selectionChanged -= OnSelectionChanged; | |
| EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; | |
| Debug.LogWarning($"[HierarchyWebServer] failed to start: {e.Message}. Web server will not start."); | |
| } | |
| } | |
| public static void StopServer() | |
| { | |
| try | |
| { | |
| _running = false; | |
| if (_listener != null) | |
| { | |
| _listener.Close(); | |
| _listener = null; | |
| } | |
| if (_thread != null) | |
| { | |
| _thread.Join(500); | |
| _thread = null; | |
| } | |
| Selection.selectionChanged -= OnSelectionChanged; | |
| EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; | |
| EditorApplication.delayCall -= RebuildSnapshotFromSelection; | |
| Debug.Log("[HierarchyWebServer] stopped."); | |
| } | |
| catch (Exception e) | |
| { | |
| Debug.LogError("[HierarchyWebServer] stop error: " + e); | |
| } | |
| } | |
| // エディタ停止時に後片付け | |
| static void OnPlayModeStateChanged(PlayModeStateChange s) | |
| { | |
| if (s == PlayModeStateChange.ExitingEditMode || s == PlayModeStateChange.ExitingPlayMode) | |
| StopServer(); | |
| } | |
| // --- 選択監視(メインスレッド / Unity API OK) --- | |
| // Selection.selectionChangedイベントを使用することで、エディタの描画中に呼ばれることを防ぐ | |
| static void OnSelectionChanged() | |
| { | |
| if (!_running) return; | |
| // エディタの描画が完了した後に処理を実行(安全) | |
| // 重複登録を防ぐために事前に削除 | |
| EditorApplication.delayCall -= RebuildSnapshotFromSelection; | |
| EditorApplication.delayCall += RebuildSnapshotFromSelection; | |
| } | |
| static void RebuildSnapshotFromSelection() | |
| { | |
| try | |
| { | |
| // 複数選択に対応 | |
| var selectedObjects = Selection.gameObjects; | |
| if (selectedObjects == null || selectedObjects.Length == 0) | |
| { | |
| _latestText = "No GameObject selected."; | |
| _latestJson = "{\"status\":\"no_selection\"}"; | |
| _latestSceneViewBase64 = ""; | |
| _latestGameViewBase64 = ""; | |
| return; | |
| } | |
| // スクリーンショットを取得 | |
| CaptureScreenshots(); | |
| // 複数オブジェクトのメタ情報を収集 | |
| var objectsList = new List<(string name, string path, string metadata, string time, string playMode)>(); | |
| var allTextSb = new StringBuilder(); | |
| var validObjects = new List<GameObject>(); | |
| foreach (var go in selectedObjects) | |
| { | |
| if (go != null && go.transform != null) | |
| { | |
| validObjects.Add(go); | |
| } | |
| } | |
| int index = 1; | |
| foreach (var go in validObjects) | |
| { | |
| // メタ情報を取得(ヘッダーなし) | |
| var metadataSb = new StringBuilder(); | |
| Traverse(go.transform, 0, metadataSb); | |
| string metadata = metadataSb.ToString(); | |
| // テキスト出力用に追加 | |
| allTextSb.AppendLine($"o{index}:{go.name}"); | |
| allTextSb.AppendLine(); | |
| allTextSb.AppendLine($"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); | |
| allTextSb.AppendLine($"PlayMode: {EditorApplication.isPlaying}"); | |
| allTextSb.AppendLine(); | |
| allTextSb.AppendLine(metadata); | |
| // 最後のオブジェクト以外は区切りを追加 | |
| if (index < validObjects.Count) | |
| { | |
| allTextSb.AppendLine(); | |
| allTextSb.AppendLine("==="); | |
| allTextSb.AppendLine(); | |
| } | |
| // JSON用にオブジェクト情報を追加(ヘッダーを含む完全なメタ情報) | |
| var currentTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); | |
| var playMode = EditorApplication.isPlaying.ToString(); | |
| var fullMetadataSb = new StringBuilder(); | |
| fullMetadataSb.AppendLine($"=== Hierarchy Metadata (Unity {Application.unityVersion}) ==="); | |
| fullMetadataSb.AppendLine($"Root: {GetPath(go.transform)}"); | |
| fullMetadataSb.AppendLine($"Time: {currentTime}"); | |
| fullMetadataSb.AppendLine($"PlayMode: {playMode}"); | |
| fullMetadataSb.AppendLine(); | |
| fullMetadataSb.Append(metadata); | |
| var jsonSafe = EscapeJson(fullMetadataSb.ToString()); | |
| objectsList.Add((go.name, GetPath(go.transform), jsonSafe, currentTime, playMode)); | |
| index++; | |
| } | |
| _latestText = allTextSb.ToString(); | |
| // JSONに画像データを含める | |
| var sceneViewBase64 = string.IsNullOrEmpty(_latestSceneViewBase64) ? "" : _latestSceneViewBase64; | |
| var gameViewBase64 = string.IsNullOrEmpty(_latestGameViewBase64) ? "" : _latestGameViewBase64; | |
| // デバッグ用ログ | |
| if (DEBUG_MODE) | |
| { | |
| Debug.Log($"[HierarchyWebServer] Selected objects: {objectsList.Count}, SceneView base64 length: {sceneViewBase64.Length}, GameView base64 length: {gameViewBase64.Length}"); | |
| } | |
| // JSON配列を構築 | |
| var objectsJson = new StringBuilder(); | |
| objectsJson.Append("["); | |
| for (int i = 0; i < objectsList.Count; i++) | |
| { | |
| if (i > 0) objectsJson.Append(","); | |
| var obj = objectsList[i]; | |
| var name = EscapeJson(obj.name); | |
| var path = EscapeJson(obj.path); | |
| var metadata = obj.metadata; | |
| var time = EscapeJson(obj.time); | |
| var playMode = EscapeJson(obj.playMode); | |
| objectsJson.Append($"{{\"name\":\"{name}\",\"path\":\"{path}\",\"metadata\":\"{metadata}\",\"time\":\"{time}\",\"playMode\":\"{playMode}\"}}"); | |
| } | |
| objectsJson.Append("]"); | |
| _latestJson = $"{{\"ok\":true,\"updated\":\"{DateTime.Now:O}\",\"objects\":{objectsJson},\"sceneView\":\"{sceneViewBase64}\",\"gameView\":\"{gameViewBase64}\"}}"; | |
| } | |
| catch (NullReferenceException) | |
| { | |
| // NullReferenceExceptionは無視(エディタの状態が不安定な時に発生する可能性がある) | |
| _latestText = "Error: Editor state is unstable. Please try again."; | |
| _latestJson = "{\"ok\":false,\"error\":\"Editor state unstable\"}"; | |
| _latestSceneViewBase64 = ""; | |
| _latestGameViewBase64 = ""; | |
| } | |
| catch (Exception e) | |
| { | |
| _latestText = "Error while building snapshot:\n" + e; | |
| _latestJson = $"{{\"ok\":false,\"error\":\"{EscapeJson(e.ToString())}\"}}"; | |
| _latestSceneViewBase64 = ""; | |
| _latestGameViewBase64 = ""; | |
| } | |
| } | |
| static string EscapeJson(string s) | |
| => s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r"); | |
| // スクリーンショット取得用の設定 | |
| const int SCREENSHOT_MAX_WIDTH = 4096; // 大きなGame View用に拡張 | |
| const int SCREENSHOT_MAX_HEIGHT = 2160; // 4K対応 | |
| const int JPEG_QUALITY = 85; // JPG品質(0-100、85が推奨) | |
| // 縦横比を維持しながら最大サイズ内に収めるヘルパーメソッド | |
| static void CalculateScreenshotSize(int originalWidth, int originalHeight, | |
| int maxWidth, int maxHeight, | |
| out int screenshotWidth, out int screenshotHeight) | |
| { | |
| if (originalWidth <= 0 || originalHeight <= 0) | |
| { | |
| screenshotWidth = maxWidth; | |
| screenshotHeight = maxHeight; | |
| return; | |
| } | |
| float aspectRatio = (float)originalWidth / originalHeight; | |
| // まず幅で制限した場合のサイズを計算 | |
| int widthBasedWidth = Mathf.Min(originalWidth, maxWidth); | |
| int widthBasedHeight = Mathf.RoundToInt(widthBasedWidth / aspectRatio); | |
| // 次に高さで制限した場合のサイズを計算 | |
| int heightBasedHeight = Mathf.Min(originalHeight, maxHeight); | |
| int heightBasedWidth = Mathf.RoundToInt(heightBasedHeight * aspectRatio); | |
| // 両方の制限を満たす方を選択 | |
| if (widthBasedWidth <= maxWidth && widthBasedHeight <= maxHeight && | |
| heightBasedWidth <= maxWidth && heightBasedHeight <= maxHeight) | |
| { | |
| // 両方とも制限内なら、より大きなサイズを選択(画質優先) | |
| if (widthBasedWidth * widthBasedHeight >= heightBasedWidth * heightBasedHeight) | |
| { | |
| screenshotWidth = widthBasedWidth; | |
| screenshotHeight = widthBasedHeight; | |
| } | |
| else | |
| { | |
| screenshotWidth = heightBasedWidth; | |
| screenshotHeight = heightBasedHeight; | |
| } | |
| } | |
| else if (widthBasedWidth <= maxWidth && widthBasedHeight <= maxHeight) | |
| { | |
| // 幅ベースが制限内 | |
| screenshotWidth = widthBasedWidth; | |
| screenshotHeight = widthBasedHeight; | |
| } | |
| else if (heightBasedWidth <= maxWidth && heightBasedHeight <= maxHeight) | |
| { | |
| // 高さベースが制限内 | |
| screenshotWidth = heightBasedWidth; | |
| screenshotHeight = heightBasedHeight; | |
| } | |
| else | |
| { | |
| // どちらも制限を超える場合は、より厳しい制限に合わせる | |
| float widthScale = (float)maxWidth / originalWidth; | |
| float heightScale = (float)maxHeight / originalHeight; | |
| float scale = Mathf.Min(widthScale, heightScale); | |
| screenshotWidth = Mathf.RoundToInt(originalWidth * scale); | |
| screenshotHeight = Mathf.RoundToInt(originalHeight * scale); | |
| } | |
| // 最小サイズを確保 | |
| screenshotWidth = Mathf.Max(screenshotWidth, 256); | |
| screenshotHeight = Mathf.Max(screenshotHeight, 256); | |
| } | |
| static void CaptureScreenshots() | |
| { | |
| try | |
| { | |
| // Scene Viewのスクリーンショット | |
| _latestSceneViewBase64 = CaptureSceneView(); | |
| // Game Viewのスクリーンショット(プレイモード中のみ) | |
| if (EditorApplication.isPlaying) | |
| { | |
| _latestGameViewBase64 = CaptureGameView(); | |
| } | |
| else | |
| { | |
| _latestGameViewBase64 = ""; | |
| } | |
| } | |
| catch (Exception e) | |
| { | |
| Debug.LogWarning($"[HierarchyWebServer] Failed to capture screenshots: {e.Message}"); | |
| _latestSceneViewBase64 = ""; | |
| _latestGameViewBase64 = ""; | |
| } | |
| } | |
| static string CaptureSceneView() | |
| { | |
| try | |
| { | |
| var sceneView = SceneView.lastActiveSceneView; | |
| if (sceneView == null) | |
| { | |
| Debug.LogWarning("[HierarchyWebServer] SceneView.lastActiveSceneView is null"); | |
| return ""; | |
| } | |
| // Scene Viewを強制的に再描画して最新の状態にする | |
| sceneView.Repaint(); | |
| // Scene Viewのカメラを取得 | |
| var sceneCamera = sceneView.camera; | |
| if (sceneCamera == null) | |
| { | |
| Debug.LogWarning("[HierarchyWebServer] SceneView camera is null"); | |
| return ""; | |
| } | |
| // Scene Viewの実際のサイズを取得して縦横比を維持 | |
| var sceneViewRect = sceneView.position; | |
| int sceneViewWidth = (int)sceneViewRect.width; | |
| int sceneViewHeight = (int)sceneViewRect.height; | |
| // 縦横比を維持しながら最大サイズ内に収める | |
| float aspectRatio = sceneViewWidth > 0 && sceneViewHeight > 0 | |
| ? (float)sceneViewWidth / sceneViewHeight | |
| : 1.0f; | |
| int screenshotWidth, screenshotHeight; | |
| CalculateScreenshotSize(sceneViewWidth, sceneViewHeight, | |
| SCREENSHOT_MAX_WIDTH, SCREENSHOT_MAX_HEIGHT, | |
| out screenshotWidth, out screenshotHeight); | |
| if (DEBUG_MODE) | |
| { | |
| Debug.Log($"[HierarchyWebServer] Scene View size: {sceneViewWidth}x{sceneViewHeight}, Screenshot size: {screenshotWidth}x{screenshotHeight}, Aspect: {aspectRatio:F2}"); | |
| } | |
| // RenderTextureを作成(縦横比を維持) | |
| var rt = RenderTexture.GetTemporary(screenshotWidth, screenshotHeight, 24); | |
| // Scene Viewのカメラを直接使用してレンダリング | |
| // これにより、Scene Viewの実際の描画内容を取得できる | |
| var prevSceneCameraTarget = sceneCamera.targetTexture; | |
| sceneCamera.targetTexture = rt; | |
| sceneCamera.Render(); | |
| sceneCamera.targetTexture = prevSceneCameraTarget; | |
| // Texture2Dに変換 | |
| RenderTexture.active = rt; | |
| var tex = new Texture2D(screenshotWidth, screenshotHeight, TextureFormat.RGB24, false); | |
| tex.ReadPixels(new Rect(0, 0, screenshotWidth, screenshotHeight), 0, 0); | |
| tex.Apply(); | |
| // JPGにエンコードしてbase64に変換 | |
| byte[] jpgData = tex.EncodeToJPG(JPEG_QUALITY); | |
| if (jpgData == null || jpgData.Length == 0) | |
| { | |
| Debug.LogWarning("[HierarchyWebServer] Failed to encode Scene View to JPG"); | |
| RenderTexture.active = null; | |
| RenderTexture.ReleaseTemporary(rt); | |
| UnityEngine.Object.DestroyImmediate(tex); | |
| return ""; | |
| } | |
| string base64 = Convert.ToBase64String(jpgData); | |
| if (DEBUG_MODE) | |
| { | |
| Debug.Log($"[HierarchyWebServer] Scene View captured successfully, JPG size: {jpgData.Length} bytes, base64 length: {base64.Length}"); | |
| } | |
| // クリーンアップ | |
| RenderTexture.active = null; | |
| RenderTexture.ReleaseTemporary(rt); | |
| UnityEngine.Object.DestroyImmediate(tex); | |
| return base64; | |
| } | |
| catch (Exception e) | |
| { | |
| Debug.LogWarning($"[HierarchyWebServer] Failed to capture Scene View: {e.Message}\n{e.StackTrace}"); | |
| return ""; | |
| } | |
| } | |
| static Bounds GetBounds(GameObject go) | |
| { | |
| var renderers = go.GetComponentsInChildren<Renderer>(); | |
| if (renderers.Length == 0) | |
| { | |
| // Rendererがない場合は、Transformの位置を中心とした小さなバウンディングボックスを返す | |
| return new Bounds(go.transform.position, Vector3.one); | |
| } | |
| var bounds = renderers[0].bounds; | |
| foreach (var renderer in renderers) | |
| { | |
| if (renderer != null) | |
| { | |
| bounds.Encapsulate(renderer.bounds); | |
| } | |
| } | |
| return bounds; | |
| } | |
| static string CaptureGameView() | |
| { | |
| try | |
| { | |
| var mainCamera = Camera.main; | |
| if (mainCamera == null) | |
| { | |
| // メインカメラがない場合は、最初に見つかったカメラを使用 | |
| var cameras = UnityEngine.Object.FindObjectsOfType<Camera>(); | |
| if (cameras.Length == 0) return ""; | |
| mainCamera = cameras[0]; | |
| } | |
| // Game Viewのサイズを取得(Handles.GetMainGameViewSize()を使用) | |
| var gameViewSize = Handles.GetMainGameViewSize(); | |
| int gameViewWidth = (int)gameViewSize.x; | |
| int gameViewHeight = (int)gameViewSize.y; | |
| // Game Viewが取得できない場合は、Screenサイズを使用 | |
| if (gameViewWidth <= 0 || gameViewHeight <= 0) | |
| { | |
| gameViewWidth = Screen.width; | |
| gameViewHeight = Screen.height; | |
| } | |
| // 縦横比を維持しながら最大サイズ内に収める | |
| float aspectRatio = gameViewWidth > 0 && gameViewHeight > 0 | |
| ? (float)gameViewWidth / gameViewHeight | |
| : 16f / 9f; // デフォルトは16:9 | |
| int screenshotWidth, screenshotHeight; | |
| CalculateScreenshotSize(gameViewWidth, gameViewHeight, | |
| SCREENSHOT_MAX_WIDTH, SCREENSHOT_MAX_HEIGHT, | |
| out screenshotWidth, out screenshotHeight); | |
| if (DEBUG_MODE) | |
| { | |
| Debug.Log($"[HierarchyWebServer] Game View size: {gameViewWidth}x{gameViewHeight}, Screenshot size: {screenshotWidth}x{screenshotHeight}, Aspect: {aspectRatio:F2}"); | |
| } | |
| // RenderTextureを作成 | |
| var rt = RenderTexture.GetTemporary(screenshotWidth, screenshotHeight, 24); | |
| var prev = mainCamera.targetTexture; | |
| mainCamera.targetTexture = rt; | |
| mainCamera.Render(); | |
| mainCamera.targetTexture = prev; | |
| // Texture2Dに変換 | |
| RenderTexture.active = rt; | |
| var tex = new Texture2D(screenshotWidth, screenshotHeight, TextureFormat.RGB24, false); | |
| tex.ReadPixels(new Rect(0, 0, screenshotWidth, screenshotHeight), 0, 0); | |
| tex.Apply(); | |
| // JPGにエンコードしてbase64に変換 | |
| byte[] jpgData = tex.EncodeToJPG(JPEG_QUALITY); | |
| if (jpgData == null || jpgData.Length == 0) | |
| { | |
| Debug.LogWarning("[HierarchyWebServer] Failed to encode Game View to JPG"); | |
| RenderTexture.active = null; | |
| RenderTexture.ReleaseTemporary(rt); | |
| UnityEngine.Object.DestroyImmediate(tex); | |
| return ""; | |
| } | |
| string base64 = Convert.ToBase64String(jpgData); | |
| if (DEBUG_MODE) | |
| { | |
| Debug.Log($"[HierarchyWebServer] Game View captured successfully, JPG size: {jpgData.Length} bytes, base64 length: {base64.Length}"); | |
| } | |
| // クリーンアップ | |
| RenderTexture.active = null; | |
| RenderTexture.ReleaseTemporary(rt); | |
| UnityEngine.Object.DestroyImmediate(tex); | |
| return base64; | |
| } | |
| catch (Exception e) | |
| { | |
| Debug.LogWarning($"[HierarchyWebServer] Failed to capture Game View: {e.Message}"); | |
| return ""; | |
| } | |
| } | |
| static void Traverse(Transform t, int depth, StringBuilder sb) | |
| { | |
| if (depth > MAX_DEPTH) return; | |
| if (t == null) return; | |
| try | |
| { | |
| string indent = new string(' ', depth * 2); | |
| var go = t.gameObject; | |
| if (go == null) return; | |
| sb.AppendLine($"{indent}- GameObject: {go.name}"); | |
| sb.AppendLine($"{indent} Path: {GetPath(t)}"); | |
| sb.AppendLine($"{indent} ActiveInHierarchy: {go.activeInHierarchy}, ActiveSelf: {go.activeSelf}"); | |
| sb.AppendLine($"{indent} Tag: {go.tag}, Layer: {LayerMask.LayerToName(go.layer)} ({go.layer})"); | |
| sb.AppendLine($"{indent} HideFlags: {go.hideFlags}"); | |
| try | |
| { | |
| var prefabStatus = PrefabUtility.GetPrefabInstanceStatus(go); | |
| if (prefabStatus != PrefabInstanceStatus.NotAPrefab) | |
| { | |
| var prefab = PrefabUtility.GetCorrespondingObjectFromSource(go); | |
| if (prefab != null) | |
| { | |
| var prefabPath = AssetDatabase.GetAssetPath(prefab); | |
| var prefabGuid = string.IsNullOrEmpty(prefabPath) ? "" : AssetDatabase.AssetPathToGUID(prefabPath); | |
| sb.AppendLine($"{indent} Prefab: {prefabStatus} Path: {prefabPath} GUID: {prefabGuid}"); | |
| } | |
| } | |
| } | |
| catch | |
| { | |
| // PrefabUtility関連のエラーは無視 | |
| } | |
| try | |
| { | |
| var comps = go.GetComponents<Component>(); | |
| foreach (var c in comps) | |
| { | |
| if (c == null) { sb.AppendLine($"{indent} * Missing Component"); continue; } | |
| sb.AppendLine($"{indent} * Component: {c.GetType().FullName}"); | |
| if (c is MonoBehaviour mb) | |
| { | |
| try | |
| { | |
| var ms = MonoScript.FromMonoBehaviour(mb); | |
| if (ms != null) | |
| { | |
| var scriptPath = AssetDatabase.GetAssetPath(ms); | |
| var scriptGuid = string.IsNullOrEmpty(scriptPath) ? "" : AssetDatabase.AssetPathToGUID(scriptPath); | |
| sb.AppendLine($"{indent} Script: {scriptPath} GUID: {scriptGuid}"); | |
| } | |
| } | |
| catch | |
| { | |
| // MonoScript関連のエラーは無視 | |
| } | |
| } | |
| // コンポーネントのシリアライズされたフィールドを取得 | |
| try | |
| { | |
| SerializedObject so = new SerializedObject(c); | |
| SerializedProperty prop = so.GetIterator(); | |
| bool enterChildren = true; | |
| while (prop.NextVisible(enterChildren)) | |
| { | |
| enterChildren = false; | |
| // m_Scriptプロパティはスキップ(既に表示済み) | |
| if (prop.name == "m_Script") continue; | |
| SerializeProperty(prop, indent + " ", sb, 0); | |
| } | |
| } | |
| catch (Exception e) | |
| { | |
| sb.AppendLine($"{indent} [Error reading properties: {e.Message}]"); | |
| } | |
| } | |
| } | |
| catch | |
| { | |
| // GetComponents関連のエラーは無視 | |
| } | |
| for (int i = 0; i < t.childCount; i++) | |
| { | |
| var child = t.GetChild(i); | |
| if (child != null) | |
| Traverse(child, depth + 1, sb); | |
| } | |
| } | |
| catch | |
| { | |
| // トラバース中のエラーは無視(エディタの状態が不安定な時に発生する可能性がある) | |
| } | |
| } | |
| static string GetPath(Transform t) | |
| { | |
| var list = new List<string>(); | |
| while (t != null) { list.Add(t.name); t = t.parent; } | |
| list.Reverse(); return string.Join("/", list); | |
| } | |
| static void SerializeProperty(SerializedProperty prop, string indent, StringBuilder sb, int depth) | |
| { | |
| const int MAX_PROPERTY_DEPTH = 8; | |
| if (depth > MAX_PROPERTY_DEPTH) return; | |
| try | |
| { | |
| string displayName = prop.displayName; | |
| string propertyType = prop.propertyType.ToString(); | |
| switch (prop.propertyType) | |
| { | |
| case SerializedPropertyType.Integer: | |
| sb.AppendLine($"{indent}{displayName}: {prop.intValue}"); | |
| break; | |
| case SerializedPropertyType.Boolean: | |
| sb.AppendLine($"{indent}{displayName}: {prop.boolValue}"); | |
| break; | |
| case SerializedPropertyType.Float: | |
| sb.AppendLine($"{indent}{displayName}: {prop.floatValue}"); | |
| break; | |
| case SerializedPropertyType.String: | |
| sb.AppendLine($"{indent}{displayName}: \"{prop.stringValue}\""); | |
| break; | |
| case SerializedPropertyType.Color: | |
| sb.AppendLine($"{indent}{displayName}: {prop.colorValue}"); | |
| break; | |
| case SerializedPropertyType.ObjectReference: | |
| if (prop.objectReferenceValue != null) | |
| { | |
| var obj = prop.objectReferenceValue; | |
| var assetPath = AssetDatabase.GetAssetPath(obj); | |
| var guid = string.IsNullOrEmpty(assetPath) ? "" : AssetDatabase.AssetPathToGUID(assetPath); | |
| sb.AppendLine($"{indent}{displayName}: {obj.name} ({obj.GetType().Name})"); | |
| if (!string.IsNullOrEmpty(assetPath)) | |
| { | |
| sb.AppendLine($"{indent} Path: {assetPath} GUID: {guid}"); | |
| } | |
| } | |
| else | |
| { | |
| sb.AppendLine($"{indent}{displayName}: None"); | |
| } | |
| break; | |
| case SerializedPropertyType.LayerMask: | |
| sb.AppendLine($"{indent}{displayName}: {prop.intValue}"); | |
| break; | |
| case SerializedPropertyType.Enum: | |
| sb.AppendLine($"{indent}{displayName}: {prop.enumNames[prop.enumValueIndex]}"); | |
| break; | |
| case SerializedPropertyType.Vector2: | |
| sb.AppendLine($"{indent}{displayName}: {prop.vector2Value}"); | |
| break; | |
| case SerializedPropertyType.Vector3: | |
| sb.AppendLine($"{indent}{displayName}: {prop.vector3Value}"); | |
| break; | |
| case SerializedPropertyType.Vector4: | |
| sb.AppendLine($"{indent}{displayName}: {prop.vector4Value}"); | |
| break; | |
| case SerializedPropertyType.Rect: | |
| sb.AppendLine($"{indent}{displayName}: {prop.rectValue}"); | |
| break; | |
| case SerializedPropertyType.ArraySize: | |
| sb.AppendLine($"{indent}{displayName}: {prop.intValue}"); | |
| break; | |
| case SerializedPropertyType.Character: | |
| sb.AppendLine($"{indent}{displayName}: '{(char)prop.intValue}'"); | |
| break; | |
| case SerializedPropertyType.AnimationCurve: | |
| sb.AppendLine($"{indent}{displayName}: AnimationCurve"); | |
| break; | |
| case SerializedPropertyType.Bounds: | |
| sb.AppendLine($"{indent}{displayName}: {prop.boundsValue}"); | |
| break; | |
| case SerializedPropertyType.Quaternion: | |
| sb.AppendLine($"{indent}{displayName}: {prop.quaternionValue}"); | |
| break; | |
| case SerializedPropertyType.ExposedReference: | |
| sb.AppendLine($"{indent}{displayName}: {prop.exposedReferenceValue}"); | |
| break; | |
| case SerializedPropertyType.Vector2Int: | |
| sb.AppendLine($"{indent}{displayName}: {prop.vector2IntValue}"); | |
| break; | |
| case SerializedPropertyType.Vector3Int: | |
| sb.AppendLine($"{indent}{displayName}: {prop.vector3IntValue}"); | |
| break; | |
| case SerializedPropertyType.RectInt: | |
| sb.AppendLine($"{indent}{displayName}: {prop.rectIntValue}"); | |
| break; | |
| case SerializedPropertyType.BoundsInt: | |
| sb.AppendLine($"{indent}{displayName}: {prop.boundsIntValue}"); | |
| break; | |
| case SerializedPropertyType.Generic: | |
| // 配列またはネストされたオブジェクト | |
| if (prop.isArray && prop.propertyType != SerializedPropertyType.String) | |
| { | |
| int arraySize = prop.arraySize; | |
| sb.AppendLine($"{indent}{displayName}: Array[{arraySize}]"); | |
| for (int i = 0; i < arraySize && i < 100; i++) // 最大100要素まで | |
| { | |
| var element = prop.GetArrayElementAtIndex(i); | |
| SerializeProperty(element, indent + " ", sb, depth + 1); | |
| } | |
| if (arraySize > 100) | |
| { | |
| sb.AppendLine($"{indent} ... ({arraySize - 100} more elements)"); | |
| } | |
| } | |
| else | |
| { | |
| // ネストされたオブジェクト | |
| sb.AppendLine($"{indent}{displayName}:"); | |
| var copy = prop.Copy(); | |
| var endProperty = copy.GetEndProperty(); | |
| copy.NextVisible(true); | |
| int childCount = 0; | |
| while (!SerializedProperty.EqualContents(copy, endProperty) && childCount < 50) | |
| { | |
| SerializeProperty(copy, indent + " ", sb, depth + 1); | |
| childCount++; | |
| if (!copy.NextVisible(false)) | |
| break; | |
| } | |
| } | |
| break; | |
| default: | |
| sb.AppendLine($"{indent}{displayName}: ({propertyType})"); | |
| break; | |
| } | |
| } | |
| catch (Exception e) | |
| { | |
| sb.AppendLine($"{indent}[Error: {e.Message}]"); | |
| } | |
| } | |
| // --- サーバ本体(別スレッド / Unity APIは使わない) --- | |
| static void HandleLoop() | |
| { | |
| while (_running && _listener != null) | |
| { | |
| HttpListenerContext ctx = null; | |
| try { ctx = _listener.GetContext(); } | |
| catch { if (!_running) break; else continue; } | |
| try | |
| { | |
| var req = ctx.Request; | |
| var res = ctx.Response; | |
| if (req.Url.AbsolutePath == "/" || req.Url.AbsolutePath == "/index.html") | |
| { | |
| var html = GetIndexHtml(); | |
| WriteUtf8(res, "text/html; charset=utf-8", html); | |
| } | |
| else if (req.Url.AbsolutePath == "/text") | |
| { | |
| WriteUtf8(res, "text/plain; charset=utf-8", _latestText); | |
| } | |
| else if (req.Url.AbsolutePath == "/json") | |
| { | |
| WriteUtf8(res, "application/json; charset=utf-8", _latestJson); | |
| } | |
| else if (req.Url.AbsolutePath == "/health") | |
| { | |
| WriteUtf8(res, "application/json; charset=utf-8", "{\"ok\":true}"); | |
| } | |
| else | |
| { | |
| res.StatusCode = 404; | |
| WriteUtf8(res, "text/plain; charset=utf-8", "404"); | |
| } | |
| } | |
| catch (Exception e) | |
| { | |
| try | |
| { | |
| ctx.Response.StatusCode = 500; | |
| WriteUtf8(ctx.Response, "text/plain; charset=utf-8", "500\n" + e); | |
| } | |
| catch { /* ignore */ } | |
| } | |
| } | |
| } | |
| static string GetIndexHtml() => @"<!doctype html> | |
| <html lang=""ja""><head> | |
| <meta charset=""utf-8""> | |
| <meta name=""viewport"" content=""width=device-width,initial-scale=1""> | |
| <title>Unity Hierarchy Metadata</title> | |
| <style> | |
| body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; margin: 0; background:#0f1214; color:#e5e7eb;} | |
| header { padding:12px 16px; background:#111827; position:sticky; top:0; display:flex; gap:12px; align-items:center; z-index:100;} | |
| #time { opacity:.7;} | |
| .images { display:flex; flex-direction:column; gap:16px; padding:16px; align-items:flex-start; } | |
| .image-container { width:100%; } | |
| .image-container h3 { margin:0 0 8px 0; font-size:14px; color:#9ca3af; } | |
| .image-container img { max-width:100%; height:auto; border:1px solid #374151; border-radius:6px; background:#1f2937; } | |
| .image-container .no-image { padding:40px; text-align:center; color:#6b7280; border:1px solid #374151; border-radius:6px; background:#1f2937; } | |
| .metadata-wrapper { width:100%; padding:16px; margin:0 16px 16px 16px; border:2px solid #374151; border-radius:8px; background:#111827; } | |
| .copy-section { width:100%; margin-bottom:16px; } | |
| .copy-section h3 { margin:0 0 12px 0; font-size:14px; color:#9ca3af; } | |
| .copy-button { width:100%; padding:12px 16px; background:#2563eb; color:#ffffff; border:none; border-radius:8px; cursor:pointer; font-size:14px; font-weight:600; transition:all 0.2s; box-shadow:0 2px 4px rgba(0,0,0,0.2); } | |
| .copy-button:hover { background:#1d4ed8; transform:translateY(-1px); box-shadow:0 4px 8px rgba(0,0,0,0.3); } | |
| .copy-button:active { transform:translateY(0); box-shadow:0 1px 2px rgba(0,0,0,0.2); background:#1e40af; } | |
| .copy-button:disabled { background:#374151; cursor:not-allowed; opacity:0.6; } | |
| .copy-success { background:#10b981 !important; } | |
| .objects-container { padding:0; } | |
| .object-item { margin-bottom:24px; } | |
| .object-item h2 { margin:0 0 8px 0; font-size:16px; color:#e5e7eb; padding:8px 12px; background:#1f2937; border:1px solid #374151; border-radius:6px; } | |
| .object-item textarea { width:100%; min-height:200px; padding:12px; background:#1f2937; color:#e5e7eb; border:1px solid #374151; border-radius:6px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; white-space:pre; overflow:auto; resize:vertical; } | |
| </style> | |
| </head><body> | |
| <header> | |
| <strong>Hierarchy Metadata</strong> | |
| <span id=""time""></span> | |
| </header> | |
| <div class=""images""> | |
| <div class=""image-container""> | |
| <h3>Scene View</h3> | |
| <div id=""sceneView""><div class=""no-image"">loading…</div></div> | |
| </div> | |
| <div class=""image-container""> | |
| <h3>Game View</h3> | |
| <div id=""gameView""><div class=""no-image"">loading…</div></div> | |
| </div> | |
| </div> | |
| <div class=""metadata-wrapper""> | |
| <div class=""copy-section""> | |
| <h3>Copy All Metadata</h3> | |
| <button id=""copy"" class=""copy-button"">すべてのメタ情報をクリップボードにコピー</button> | |
| </div> | |
| <div class=""objects-container"" id=""objectsContainer"">loading…</div> | |
| </div> | |
| <script> | |
| let timer=null; | |
| let lastDataHash = null; | |
| let lastUpdated = null; | |
| const DEBUG_MODE = false; // デバッグ時のみtrueに設定 | |
| // textareaがフォーカスされているか、選択中かをチェック | |
| function isTextareaActive(){ | |
| const activeElement = document.activeElement; | |
| if(activeElement && activeElement.tagName === 'TEXTAREA'){ | |
| return true; | |
| } | |
| // 選択範囲があるかチェック | |
| const selection = window.getSelection(); | |
| if(selection && selection.rangeCount > 0){ | |
| const range = selection.getRangeAt(0); | |
| const container = range.commonAncestorContainer; | |
| // textarea内の選択かチェック | |
| let node = container.nodeType === Node.TEXT_NODE ? container.parentNode : container; | |
| while(node){ | |
| if(node.tagName === 'TEXTAREA'){ | |
| return true; | |
| } | |
| node = node.parentNode; | |
| } | |
| } | |
| return false; | |
| } | |
| // データのハッシュを計算(簡易版) | |
| function calculateDataHash(data){ | |
| if(!data || !data.ok) return null; | |
| const objects = data.objects || []; | |
| let hash = objects.length + '|'; | |
| for(let i = 0; i < objects.length; i++){ | |
| const obj = objects[i]; | |
| hash += (obj.name || '') + '|' + (obj.metadata ? obj.metadata.length : 0) + '|'; | |
| } | |
| hash += (data.updated || ''); | |
| return hash; | |
| } | |
| async function tick(){ | |
| try{ | |
| const r = await fetch('/json?ts='+Date.now(), {cache:'no-store'}); | |
| if(!r.ok){ | |
| throw new Error('HTTP error: '+r.status); | |
| } | |
| const data = await r.json(); | |
| if(DEBUG_MODE){ | |
| console.log('JSON data received:', { | |
| ok: data.ok, | |
| objectsCount: data.objects ? data.objects.length : 0, | |
| sceneViewLength: data.sceneView ? data.sceneView.length : 0, | |
| gameViewLength: data.gameView ? data.gameView.length : 0 | |
| }); | |
| } | |
| // データが変更されたかチェック | |
| const currentDataHash = calculateDataHash(data); | |
| const dataChanged = currentDataHash !== lastDataHash; | |
| // textareaがアクティブな場合は、データが変更されていても更新をスキップ | |
| const textareaActive = isTextareaActive(); | |
| if(data.ok){ | |
| // Scene View画像を更新(常に更新) | |
| const sceneViewDiv = document.getElementById('sceneView'); | |
| if(data.sceneView && data.sceneView.length > 0){ | |
| const img = document.createElement('img'); | |
| img.src = 'data:image/jpeg;base64,'+data.sceneView; | |
| img.alt = 'Scene View'; | |
| img.onerror = function(e){ | |
| console.error('Failed to load Scene View image:', e); | |
| sceneViewDiv.innerHTML = '<div class=""no-image"">Failed to load Scene View image</div>'; | |
| }; | |
| img.onload = function(){ | |
| if(DEBUG_MODE){ | |
| console.log('Scene View image loaded successfully'); | |
| } | |
| }; | |
| sceneViewDiv.innerHTML = ''; | |
| sceneViewDiv.appendChild(img); | |
| }else{ | |
| sceneViewDiv.innerHTML = '<div class=""no-image"">No Scene View available</div>'; | |
| } | |
| // Game View画像を更新(常に更新) | |
| const gameViewDiv = document.getElementById('gameView'); | |
| if(data.gameView && data.gameView.length > 0){ | |
| const img = document.createElement('img'); | |
| img.src = 'data:image/jpeg;base64,'+data.gameView; | |
| img.alt = 'Game View'; | |
| img.onerror = function(e){ | |
| console.error('Failed to load Game View image:', e); | |
| gameViewDiv.innerHTML = '<div class=""no-image"">Failed to load Game View image</div>'; | |
| }; | |
| img.onload = function(){ | |
| if(DEBUG_MODE){ | |
| console.log('Game View image loaded successfully'); | |
| } | |
| }; | |
| gameViewDiv.innerHTML = ''; | |
| gameViewDiv.appendChild(img); | |
| }else{ | |
| gameViewDiv.innerHTML = '<div class=""no-image"">No Game View (not in Play Mode)</div>'; | |
| } | |
| // オブジェクトリストを更新(データが変更された場合のみ、かつtextareaがアクティブでない場合) | |
| const objectsContainer = document.getElementById('objectsContainer'); | |
| if(dataChanged && !textareaActive){ | |
| if(data.objects && data.objects.length > 0){ | |
| let html = ''; | |
| for(let i = 0; i < data.objects.length; i++){ | |
| const obj = data.objects[i]; | |
| const name = obj.name || 'Unknown'; | |
| const metadata = obj.metadata || ''; | |
| html += '<div class=""object-item"">'; | |
| html += '<h2>o' + (i + 1) + ': ' + escapeHtml(name) + '</h2>'; | |
| html += '<textarea readonly>' + escapeHtml(metadata) + '</textarea>'; | |
| html += '</div>'; | |
| } | |
| objectsContainer.innerHTML = html; | |
| }else{ | |
| objectsContainer.innerHTML = '<div class=""no-image"">No objects selected</div>'; | |
| } | |
| lastDataHash = currentDataHash; | |
| }else if(!dataChanged && !textareaActive){ | |
| // 初回ロード時は必ず更新 | |
| if(lastDataHash === null){ | |
| if(data.objects && data.objects.length > 0){ | |
| let html = ''; | |
| for(let i = 0; i < data.objects.length; i++){ | |
| const obj = data.objects[i]; | |
| const name = obj.name || 'Unknown'; | |
| const metadata = obj.metadata || ''; | |
| html += '<div class=""object-item"">'; | |
| html += '<h2>o' + (i + 1) + ': ' + escapeHtml(name) + '</h2>'; | |
| html += '<textarea readonly>' + escapeHtml(metadata) + '</textarea>'; | |
| html += '</div>'; | |
| } | |
| objectsContainer.innerHTML = html; | |
| }else{ | |
| objectsContainer.innerHTML = '<div class=""no-image"">No objects selected</div>'; | |
| } | |
| lastDataHash = currentDataHash; | |
| } | |
| } | |
| }else{ | |
| // エラー時は常に更新 | |
| document.getElementById('objectsContainer').innerHTML = '<div class=""no-image"">Error: '+(data.error || 'Unknown error')+'</div>'; | |
| lastDataHash = null; | |
| } | |
| // 最終更新日時を表示(常に更新) | |
| if(data.ok && data.updated){ | |
| try { | |
| const updatedDate = new Date(data.updated); | |
| const formattedDate = updatedDate.toLocaleString('ja-JP', { | |
| year: 'numeric', | |
| month: '2-digit', | |
| day: '2-digit', | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| second: '2-digit' | |
| }); | |
| document.getElementById('time').textContent = formattedDate; | |
| lastUpdated = data.updated; | |
| } catch(e) { | |
| document.getElementById('time').textContent = data.updated || '-'; | |
| lastUpdated = data.updated; | |
| } | |
| } else { | |
| document.getElementById('time').textContent = '-'; | |
| lastUpdated = null; | |
| } | |
| }catch(e){ | |
| console.error('Fetch error:', e); | |
| document.getElementById('objectsContainer').innerHTML = '<div class=""no-image"">fetch error: '+escapeHtml(e.toString())+'</div>'; | |
| lastDataHash = null; | |
| } | |
| } | |
| function escapeHtml(text){ | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| timer = setInterval(tick, 1000); | |
| tick(); | |
| document.getElementById('copy').onclick = async () => { | |
| const copyButton = document.getElementById('copy'); | |
| const objectsContainer = document.getElementById('objectsContainer'); | |
| const objectItems = objectsContainer.querySelectorAll('.object-item'); | |
| if(objectItems.length === 0){ | |
| copyButton.textContent = 'コピーするオブジェクトがありません'; | |
| copyButton.disabled = true; | |
| setTimeout(() => { | |
| copyButton.textContent = 'すべてのメタ情報をクリップボードにコピー'; | |
| copyButton.disabled = false; | |
| }, 2000); | |
| return; | |
| } | |
| let allText = ''; | |
| for(let i = 0; i < objectItems.length; i++){ | |
| const item = objectItems[i]; | |
| const h2 = item.querySelector('h2'); | |
| const textarea = item.querySelector('textarea'); | |
| if(h2 && textarea){ | |
| const objName = h2.textContent; | |
| const metadata = textarea.value; | |
| // メタ情報からTimeとPlayModeを抽出(metadataにはヘッダーが含まれている) | |
| const timeMatch = metadata.match(/Time: ([^\\n]+)/); | |
| const playModeMatch = metadata.match(/PlayMode: ([^\\n]+)/); | |
| const time = timeMatch ? timeMatch[1] : ''; | |
| const playMode = playModeMatch ? playModeMatch[1] : ''; | |
| // メタ情報からヘッダー部分を削除(=== Hierarchy Metadata...から始まる部分とRoot:、Time:、PlayMode:の行を削除) | |
| let cleanMetadata = metadata.replace(/=== Hierarchy Metadata[^\\n]*\\n/g, ''); | |
| cleanMetadata = cleanMetadata.replace(/Root: [^\\n]*\\n/g, ''); | |
| cleanMetadata = cleanMetadata.replace(/Time: [^\\n]*\\n/g, ''); | |
| cleanMetadata = cleanMetadata.replace(/PlayMode: [^\\n]*\\n/g, ''); | |
| cleanMetadata = cleanMetadata.replace(/^\\n+/g, ''); // 先頭の空行を削除 | |
| allText += 'o' + (i + 1) + ':' + objName + '\\n'; | |
| allText += '\\n'; | |
| allText += 'Time: ' + time + '\\n'; | |
| allText += 'PlayMode: ' + playMode + '\\n'; | |
| allText += '\\n'; | |
| allText += cleanMetadata; | |
| if(i < objectItems.length - 1){ | |
| allText += '\\n\\n'; | |
| allText += '===\\n'; | |
| allText += '\\n'; | |
| } | |
| } | |
| } | |
| try { | |
| copyButton.disabled = true; | |
| copyButton.textContent = 'コピー中...'; | |
| await navigator.clipboard.writeText(allText); | |
| copyButton.classList.add('copy-success'); | |
| copyButton.textContent = '✓ コピー完了!'; | |
| setTimeout(() => { | |
| copyButton.classList.remove('copy-success'); | |
| copyButton.textContent = 'すべてのメタ情報をクリップボードにコピー'; | |
| copyButton.disabled = false; | |
| }, 2000); | |
| } catch(e){ | |
| copyButton.textContent = 'コピーに失敗しました'; | |
| copyButton.disabled = true; | |
| setTimeout(() => { | |
| copyButton.textContent = 'すべてのメタ情報をクリップボードにコピー'; | |
| copyButton.disabled = false; | |
| }, 2000); | |
| } | |
| }; | |
| </script> | |
| </body></html>"; | |
| static void WriteUtf8(HttpListenerResponse res, string contentType, string body) | |
| { | |
| var bytes = Encoding.UTF8.GetBytes(body); | |
| res.Headers["Access-Control-Allow-Origin"] = "*"; // 必要に応じて絞る | |
| res.ContentType = contentType; | |
| res.ContentLength64 = bytes.Length; | |
| using (var s = res.OutputStream) | |
| s.Write(bytes, 0, bytes.Length); | |
| } | |
| } | |
| // --- エディタウィンドウ --- | |
| public class HierarchyMetadataWindow : EditorWindow | |
| { | |
| private Vector2 _scrollPosition; | |
| private string _displayText = "No GameObject selected."; | |
| private bool _autoRefresh = true; | |
| [MenuItem("Window/Hierarchy Metadata Viewer")] | |
| public static void ShowWindow() | |
| { | |
| var window = GetWindow<HierarchyMetadataWindow>("Hierarchy Metadata"); | |
| window.minSize = new Vector2(400, 300); | |
| window.Show(); | |
| } | |
| void OnEnable() | |
| { | |
| // 選択変更イベントを登録 | |
| Selection.selectionChanged += OnSelectionChanged; | |
| // 初回更新(エディタの描画が完了した後に実行) | |
| EditorApplication.delayCall += RefreshData; | |
| } | |
| void OnDisable() | |
| { | |
| Selection.selectionChanged -= OnSelectionChanged; | |
| EditorApplication.delayCall -= RefreshData; | |
| } | |
| void OnSelectionChanged() | |
| { | |
| if (_autoRefresh) | |
| { | |
| // エディタの描画が完了した後に処理を実行(安全) | |
| EditorApplication.delayCall -= RefreshData; | |
| EditorApplication.delayCall += RefreshData; | |
| } | |
| } | |
| void RefreshData() | |
| { | |
| // 複数選択に対応 | |
| var selectedObjects = Selection.gameObjects; | |
| if (selectedObjects == null || selectedObjects.Length == 0) | |
| { | |
| _displayText = "No GameObject selected."; | |
| } | |
| else | |
| { | |
| try | |
| { | |
| var sb = new StringBuilder(); | |
| var validObjects = new List<GameObject>(); | |
| foreach (var go in selectedObjects) | |
| { | |
| if (go != null && go.transform != null) | |
| { | |
| validObjects.Add(go); | |
| } | |
| } | |
| int index = 1; | |
| foreach (var go in validObjects) | |
| { | |
| // オブジェクト番号と名前 | |
| sb.AppendLine($"o{index}:{go.name}"); | |
| sb.AppendLine(); | |
| // TimeとPlayMode | |
| sb.AppendLine($"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); | |
| sb.AppendLine($"PlayMode: {EditorApplication.isPlaying}"); | |
| sb.AppendLine(); | |
| // メタ情報 | |
| TraverseForWindow(go.transform, 0, sb); | |
| // 最後のオブジェクト以外は区切りを追加 | |
| if (index < validObjects.Count) | |
| { | |
| sb.AppendLine(); | |
| sb.AppendLine("==="); | |
| sb.AppendLine(); | |
| } | |
| index++; | |
| } | |
| _displayText = sb.ToString(); | |
| } | |
| catch (Exception e) | |
| { | |
| _displayText = $"Error: {e.Message}"; | |
| } | |
| } | |
| Repaint(); | |
| } | |
| void OnGUI() | |
| { | |
| // ツールバー | |
| EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); | |
| _autoRefresh = GUILayout.Toggle(_autoRefresh, "Auto Refresh", EditorStyles.toolbarButton, GUILayout.Width(100)); | |
| if (GUILayout.Button("Refresh", EditorStyles.toolbarButton, GUILayout.Width(60))) | |
| { | |
| RefreshData(); | |
| } | |
| GUILayout.FlexibleSpace(); | |
| // Web Server Start/Stopボタン | |
| if (HierarchyWebServer.IsRunning) | |
| { | |
| GUI.color = Color.green; | |
| GUILayout.Label("Web Server: Running", EditorStyles.toolbarButton, GUILayout.Width(120)); | |
| GUI.color = Color.white; | |
| if (GUILayout.Button("Stop", EditorStyles.toolbarButton, GUILayout.Width(50))) | |
| { | |
| HierarchyWebServer.StopServer(); | |
| RefreshData(); | |
| } | |
| } | |
| else | |
| { | |
| GUI.color = Color.gray; | |
| GUILayout.Label("Web Server: Stopped", EditorStyles.toolbarButton, GUILayout.Width(120)); | |
| GUI.color = Color.white; | |
| if (GUILayout.Button("Start", EditorStyles.toolbarButton, GUILayout.Width(50))) | |
| { | |
| HierarchyWebServer.StartServer(); | |
| RefreshData(); | |
| } | |
| } | |
| EditorGUILayout.EndHorizontal(); | |
| // コピーボタン | |
| EditorGUILayout.BeginHorizontal(); | |
| if (GUILayout.Button("Copy to Clipboard", GUILayout.Height(25))) | |
| { | |
| EditorGUIUtility.systemCopyBuffer = _displayText; | |
| Debug.Log("[HierarchyMetadataWindow] Copied to clipboard."); | |
| } | |
| EditorGUILayout.EndHorizontal(); | |
| // スクロール可能なテキスト表示 | |
| _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); | |
| EditorGUILayout.TextArea(_displayText, EditorStyles.textArea, GUILayout.ExpandHeight(true)); | |
| EditorGUILayout.EndScrollView(); | |
| } | |
| static string GetPath(Transform t) | |
| { | |
| var list = new List<string>(); | |
| while (t != null) { list.Add(t.name); t = t.parent; } | |
| list.Reverse(); | |
| return string.Join("/", list); | |
| } | |
| static void TraverseForWindow(Transform t, int depth, StringBuilder sb) | |
| { | |
| if (depth > 64) return; | |
| if (t == null) return; | |
| try | |
| { | |
| string indent = new string(' ', depth * 2); | |
| var go = t.gameObject; | |
| if (go == null) return; | |
| sb.AppendLine($"{indent}- GameObject: {go.name}"); | |
| sb.AppendLine($"{indent} Path: {GetPath(t)}"); | |
| sb.AppendLine($"{indent} ActiveInHierarchy: {go.activeInHierarchy}, ActiveSelf: {go.activeSelf}"); | |
| sb.AppendLine($"{indent} Tag: {go.tag}, Layer: {LayerMask.LayerToName(go.layer)} ({go.layer})"); | |
| sb.AppendLine($"{indent} HideFlags: {go.hideFlags}"); | |
| try | |
| { | |
| var prefabStatus = PrefabUtility.GetPrefabInstanceStatus(go); | |
| if (prefabStatus != PrefabInstanceStatus.NotAPrefab) | |
| { | |
| var prefab = PrefabUtility.GetCorrespondingObjectFromSource(go); | |
| if (prefab != null) | |
| { | |
| var prefabPath = AssetDatabase.GetAssetPath(prefab); | |
| var prefabGuid = string.IsNullOrEmpty(prefabPath) ? "" : AssetDatabase.AssetPathToGUID(prefabPath); | |
| sb.AppendLine($"{indent} Prefab: {prefabStatus} Path: {prefabPath} GUID: {prefabGuid}"); | |
| } | |
| } | |
| } | |
| catch { } | |
| try | |
| { | |
| var comps = go.GetComponents<Component>(); | |
| foreach (var c in comps) | |
| { | |
| if (c == null) { sb.AppendLine($"{indent} * Missing Component"); continue; } | |
| sb.AppendLine($"{indent} * Component: {c.GetType().FullName}"); | |
| if (c is MonoBehaviour mb) | |
| { | |
| try | |
| { | |
| var ms = MonoScript.FromMonoBehaviour(mb); | |
| if (ms != null) | |
| { | |
| var scriptPath = AssetDatabase.GetAssetPath(ms); | |
| var scriptGuid = string.IsNullOrEmpty(scriptPath) ? "" : AssetDatabase.AssetPathToGUID(scriptPath); | |
| sb.AppendLine($"{indent} Script: {scriptPath} GUID: {scriptGuid}"); | |
| } | |
| } | |
| catch { } | |
| } | |
| // コンポーネントのシリアライズされたフィールドを取得 | |
| try | |
| { | |
| SerializedObject so = new SerializedObject(c); | |
| SerializedProperty prop = so.GetIterator(); | |
| bool enterChildren = true; | |
| while (prop.NextVisible(enterChildren)) | |
| { | |
| enterChildren = false; | |
| // m_Scriptプロパティはスキップ(既に表示済み) | |
| if (prop.name == "m_Script") continue; | |
| SerializeProperty(prop, indent + " ", sb, 0); | |
| } | |
| } | |
| catch (Exception e) | |
| { | |
| sb.AppendLine($"{indent} [Error reading properties: {e.Message}]"); | |
| } | |
| } | |
| } | |
| catch { } | |
| for (int i = 0; i < t.childCount; i++) | |
| { | |
| var child = t.GetChild(i); | |
| if (child != null) | |
| TraverseForWindow(child, depth + 1, sb); | |
| } | |
| } | |
| catch { } | |
| } | |
| static void SerializeProperty(SerializedProperty prop, string indent, StringBuilder sb, int depth) | |
| { | |
| const int MAX_PROPERTY_DEPTH = 8; | |
| if (depth > MAX_PROPERTY_DEPTH) return; | |
| try | |
| { | |
| string displayName = prop.displayName; | |
| string propertyType = prop.propertyType.ToString(); | |
| switch (prop.propertyType) | |
| { | |
| case SerializedPropertyType.Integer: | |
| sb.AppendLine($"{indent}{displayName}: {prop.intValue}"); | |
| break; | |
| case SerializedPropertyType.Boolean: | |
| sb.AppendLine($"{indent}{displayName}: {prop.boolValue}"); | |
| break; | |
| case SerializedPropertyType.Float: | |
| sb.AppendLine($"{indent}{displayName}: {prop.floatValue}"); | |
| break; | |
| case SerializedPropertyType.String: | |
| sb.AppendLine($"{indent}{displayName}: \"{prop.stringValue}\""); | |
| break; | |
| case SerializedPropertyType.Color: | |
| sb.AppendLine($"{indent}{displayName}: {prop.colorValue}"); | |
| break; | |
| case SerializedPropertyType.ObjectReference: | |
| if (prop.objectReferenceValue != null) | |
| { | |
| var obj = prop.objectReferenceValue; | |
| var assetPath = AssetDatabase.GetAssetPath(obj); | |
| var guid = string.IsNullOrEmpty(assetPath) ? "" : AssetDatabase.AssetPathToGUID(assetPath); | |
| sb.AppendLine($"{indent}{displayName}: {obj.name} ({obj.GetType().Name})"); | |
| if (!string.IsNullOrEmpty(assetPath)) | |
| { | |
| sb.AppendLine($"{indent} Path: {assetPath} GUID: {guid}"); | |
| } | |
| } | |
| else | |
| { | |
| sb.AppendLine($"{indent}{displayName}: None"); | |
| } | |
| break; | |
| case SerializedPropertyType.LayerMask: | |
| sb.AppendLine($"{indent}{displayName}: {prop.intValue}"); | |
| break; | |
| case SerializedPropertyType.Enum: | |
| sb.AppendLine($"{indent}{displayName}: {prop.enumNames[prop.enumValueIndex]}"); | |
| break; | |
| case SerializedPropertyType.Vector2: | |
| sb.AppendLine($"{indent}{displayName}: {prop.vector2Value}"); | |
| break; | |
| case SerializedPropertyType.Vector3: | |
| sb.AppendLine($"{indent}{displayName}: {prop.vector3Value}"); | |
| break; | |
| case SerializedPropertyType.Vector4: | |
| sb.AppendLine($"{indent}{displayName}: {prop.vector4Value}"); | |
| break; | |
| case SerializedPropertyType.Rect: | |
| sb.AppendLine($"{indent}{displayName}: {prop.rectValue}"); | |
| break; | |
| case SerializedPropertyType.ArraySize: | |
| sb.AppendLine($"{indent}{displayName}: {prop.intValue}"); | |
| break; | |
| case SerializedPropertyType.Character: | |
| sb.AppendLine($"{indent}{displayName}: '{(char)prop.intValue}'"); | |
| break; | |
| case SerializedPropertyType.AnimationCurve: | |
| sb.AppendLine($"{indent}{displayName}: AnimationCurve"); | |
| break; | |
| case SerializedPropertyType.Bounds: | |
| sb.AppendLine($"{indent}{displayName}: {prop.boundsValue}"); | |
| break; | |
| case SerializedPropertyType.Quaternion: | |
| sb.AppendLine($"{indent}{displayName}: {prop.quaternionValue}"); | |
| break; | |
| case SerializedPropertyType.ExposedReference: | |
| sb.AppendLine($"{indent}{displayName}: {prop.exposedReferenceValue}"); | |
| break; | |
| case SerializedPropertyType.Vector2Int: | |
| sb.AppendLine($"{indent}{displayName}: {prop.vector2IntValue}"); | |
| break; | |
| case SerializedPropertyType.Vector3Int: | |
| sb.AppendLine($"{indent}{displayName}: {prop.vector3IntValue}"); | |
| break; | |
| case SerializedPropertyType.RectInt: | |
| sb.AppendLine($"{indent}{displayName}: {prop.rectIntValue}"); | |
| break; | |
| case SerializedPropertyType.BoundsInt: | |
| sb.AppendLine($"{indent}{displayName}: {prop.boundsIntValue}"); | |
| break; | |
| case SerializedPropertyType.Generic: | |
| // 配列またはネストされたオブジェクト | |
| if (prop.isArray && prop.propertyType != SerializedPropertyType.String) | |
| { | |
| int arraySize = prop.arraySize; | |
| sb.AppendLine($"{indent}{displayName}: Array[{arraySize}]"); | |
| for (int i = 0; i < arraySize && i < 100; i++) // 最大100要素まで | |
| { | |
| var element = prop.GetArrayElementAtIndex(i); | |
| SerializeProperty(element, indent + " ", sb, depth + 1); | |
| } | |
| if (arraySize > 100) | |
| { | |
| sb.AppendLine($"{indent} ... ({arraySize - 100} more elements)"); | |
| } | |
| } | |
| else | |
| { | |
| // ネストされたオブジェクト | |
| sb.AppendLine($"{indent}{displayName}:"); | |
| var copy = prop.Copy(); | |
| var endProperty = copy.GetEndProperty(); | |
| copy.NextVisible(true); | |
| int childCount = 0; | |
| while (!SerializedProperty.EqualContents(copy, endProperty) && childCount < 50) | |
| { | |
| SerializeProperty(copy, indent + " ", sb, depth + 1); | |
| childCount++; | |
| if (!copy.NextVisible(false)) | |
| break; | |
| } | |
| } | |
| break; | |
| default: | |
| sb.AppendLine($"{indent}{displayName}: ({propertyType})"); | |
| break; | |
| } | |
| } | |
| catch (Exception e) | |
| { | |
| sb.AppendLine($"{indent}[Error: {e.Message}]"); | |
| } | |
| } | |
| } | |
| #endif |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment