chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖

This commit is contained in:
Liubing\LB
2026-09-28 18:14:59 +08:00
parent 0eb43b2b40
commit c4ae02def9
952 changed files with 70137 additions and 11 deletions

View File

@@ -0,0 +1,95 @@
using System;
using System.Runtime.CompilerServices;
using System.Threading;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Frame-based asynchronous waiting (main thread non-blocking)
/// UniTask.Delay()-style implementation for Unity Editor
/// </summary>
public static class EditorDelay
{
/// <summary>
/// Wait for the specified number of frames
/// </summary>
/// <param name="frameCount">Number of frames to wait (default: 1)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Awaitable structure</returns>
public static DelayFrameAwaitable DelayFrame(int frameCount = 1, CancellationToken cancellationToken = default)
{
return new DelayFrameAwaitable(frameCount, cancellationToken);
}
}
/// <summary>
/// Awaitable structure for frame waiting
/// Can be used with async/await
/// </summary>
public struct DelayFrameAwaitable
{
private readonly int _frameCount;
private readonly CancellationToken _cancellationToken;
public DelayFrameAwaitable(int frameCount, CancellationToken cancellationToken)
{
this._frameCount = frameCount;
this._cancellationToken = cancellationToken;
}
public Awaiter GetAwaiter() => new(_frameCount, _cancellationToken);
/// <summary>
/// Awaiter structure for async/await
/// </summary>
public struct Awaiter : INotifyCompletion
{
private readonly int _frameCount;
private readonly CancellationToken _cancellationToken;
public Awaiter(int frameCount, CancellationToken cancellationToken)
{
this._frameCount = frameCount;
this._cancellationToken = cancellationToken;
}
/// <summary>
/// Whether waiting is completed
/// </summary>
public bool IsCompleted
{
get
{
_cancellationToken.ThrowIfCancellationRequested();
return _frameCount <= 0; // Complete immediately if 0 frames or less
}
}
/// <summary>
/// Get result when waiting is completed
/// </summary>
public void GetResult()
{
_cancellationToken.ThrowIfCancellationRequested();
}
/// <summary>
/// Register continuation processing
/// Code after await is passed as continuation
/// </summary>
public void OnCompleted(Action continuation)
{
_cancellationToken.ThrowIfCancellationRequested();
if (IsCompleted)
{
continuation();
return;
}
// Register continuation processing to EditorDelayManager
EditorDelayManager.RegisterDelay(continuation, _frameCount, _cancellationToken);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8736979d7836d4547bbbf5abc9209c3c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.Threading;
using UnityEditor;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Delay processing management class for Unity Editor
/// Driven by EditorApplication.update to manage frame-based waiting processes
/// </summary>
[InitializeOnLoad]
public static class EditorDelayManager
{
private static readonly List<DelayTask> delayTasks = new();
private static readonly object lockObject = new();
private static int currentFrameCount = 0;
/// <summary>
/// Class representing a waiting task
/// </summary>
private class DelayTask
{
public Action Continuation { get; }
public int RemainingFrames { get; set; }
public CancellationToken CancellationToken { get; }
public DelayTask(Action continuation, int frames, CancellationToken cancellationToken)
{
Continuation = continuation ?? throw new ArgumentNullException(nameof(continuation));
RemainingFrames = frames;
CancellationToken = cancellationToken;
}
}
/// <summary>
/// Static constructor
/// Register frame processing to EditorApplication.update
/// </summary>
static EditorDelayManager()
{
EditorApplication.update += UpdateDelayTasks;
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
}
/// <summary>
/// Register a new waiting task
/// </summary>
/// <param name="continuation">Process to execute after waiting completion</param>
/// <param name="frames">Number of frames to wait</param>
/// <param name="cancellationToken">Cancellation token</param>
public static void RegisterDelay(Action continuation, int frames, CancellationToken cancellationToken)
{
if (continuation == null)
{
return;
}
if (frames <= 0)
{
// Execute immediately if 0 frames or less
continuation.Invoke();
return;
}
lock (lockObject)
{
delayTasks.Add(new DelayTask(continuation, frames, cancellationToken));
}
}
/// <summary>
/// Update processing for waiting tasks called every frame
/// </summary>
private static void UpdateDelayTasks()
{
// Update frame counter
currentFrameCount++;
if (delayTasks.Count == 0) return;
lock (lockObject)
{
for (int i = delayTasks.Count - 1; i >= 0; i--)
{
DelayTask task = delayTasks[i];
// Remove cancelled tasks by throwing exceptions
if (task.CancellationToken.IsCancellationRequested)
{
delayTasks.RemoveAt(i);
task.Continuation.Invoke();
continue;
}
// Decrease frame count
task.RemainingFrames--;
// Execute and remove completed waiting tasks
if (task.RemainingFrames <= 0)
{
delayTasks.RemoveAt(i);
task.Continuation.Invoke();
}
}
}
}
/// <summary>
/// Get current number of waiting tasks (for debugging)
/// </summary>
public static int PendingTaskCount
{
get
{
lock (lockObject)
{
return delayTasks.Count;
}
}
}
/// <summary>
/// Get current frame count (for testing)
/// </summary>
public static int CurrentFrameCount
{
get
{
lock (lockObject)
{
return currentFrameCount;
}
}
}
/// <summary>
/// Event handler for PlayMode state changes
/// </summary>
private static void OnPlayModeStateChanged(PlayModeStateChange state)
{
if (state == PlayModeStateChange.ExitingEditMode || state == PlayModeStateChange.ExitingPlayMode)
{
ResetFrameCount();
}
}
/// <summary>
/// Reset frame counter (for testing and internal use)
/// </summary>
public static void ResetFrameCount()
{
lock (lockObject)
{
currentFrameCount = 0;
}
}
/// <summary>
/// Clear all waiting tasks (for testing)
/// </summary>
public static void ClearAllTasks()
{
lock (lockObject)
{
delayTasks.Clear();
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d43e1f6aceb664627af14cbfdd451ef5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,209 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Utility class for capturing any EditorWindow to a Texture2D.
/// Uses InternalEditorUtilityBridge to access Unity internal GrabPixels method.
/// </summary>
public static class EditorWindowCaptureUtility
{
/// <summary>
/// Find all EditorWindows matching the given name (title bar text).
/// </summary>
/// <param name="windowName">Window name displayed in the title bar (e.g., "Console", "Inspector")</param>
/// <param name="matchMode">Matching mode: exact, prefix, or contains (all case-insensitive)</param>
/// <returns>Array of matching EditorWindows (empty if none found)</returns>
public static EditorWindow[] FindWindowsByName(string windowName, WindowMatchMode matchMode = WindowMatchMode.exact)
{
if (string.IsNullOrEmpty(windowName))
{
return Array.Empty<EditorWindow>();
}
List<EditorWindow> matchingWindows = new List<EditorWindow>();
EditorWindow[] allWindows = Resources.FindObjectsOfTypeAll<EditorWindow>();
foreach (EditorWindow window in allWindows)
{
if (window.titleContent == null)
{
continue;
}
string title = window.titleContent.text;
bool isMatch = matchMode switch
{
WindowMatchMode.exact => title.Equals(windowName, StringComparison.OrdinalIgnoreCase),
WindowMatchMode.prefix => title.StartsWith(windowName, StringComparison.OrdinalIgnoreCase),
WindowMatchMode.contains => title.Contains(windowName, StringComparison.OrdinalIgnoreCase),
_ => title.Equals(windowName, StringComparison.OrdinalIgnoreCase)
};
if (isMatch)
{
matchingWindows.Add(window);
}
}
return matchingWindows.ToArray();
}
/// <summary>
/// Capture an EditorWindow to a Texture2D asynchronously.
/// Waits for 2 frames after showing the window to ensure it is fully rendered.
/// </summary>
/// <param name="window">The EditorWindow to capture</param>
/// <param name="resolutionScale">Resolution scale (0.1 to 1.0)</param>
/// <param name="ct">Cancellation token</param>
/// <returns>Captured Texture2D, or null if capture failed</returns>
public static async Task<Texture2D?> CaptureWindowAsync(EditorWindow window, float resolutionScale, CancellationToken ct)
{
if (window == null)
{
return null;
}
window.ShowTab();
await EditorDelay.DelayFrame(2, ct);
return CaptureWindowInternal(window, resolutionScale);
}
/// <summary>
/// Internal capture logic shared by sync and async methods.
/// </summary>
private static Texture2D? CaptureWindowInternal(EditorWindow window, float resolutionScale)
{
float scale = EditorGUIUtility.pixelsPerPoint;
int width = Mathf.RoundToInt(window.position.width * scale);
int height = Mathf.RoundToInt(window.position.height * scale);
if (width <= 0 || height <= 0)
{
return null;
}
// For Linear color space, disable the sRGB flag to prevent double gamma conversion
RenderTextureDescriptor descriptor = new RenderTextureDescriptor(width, height, RenderTextureFormat.ARGB32, 24);
if (QualitySettings.activeColorSpace == ColorSpace.Linear)
{
descriptor.sRGB = false;
}
RenderTexture rt = RenderTexture.GetTemporary(descriptor);
InternalEditorUtilityBridge.CaptureEditorWindow(window, rt);
RenderTexture previousActive = RenderTexture.active;
RenderTexture.active = rt;
Texture2D texture = new Texture2D(width, height, TextureFormat.RGB24, false);
texture.ReadPixels(new Rect(0, 0, width, height), 0, 0);
texture.Apply();
RenderTexture.active = previousActive;
RenderTexture.ReleaseTemporary(rt);
if (!Mathf.Approximately(resolutionScale, 1.0f))
{
texture = ApplyResolutionScaling(texture, resolutionScale);
}
return texture;
}
/// <summary>
/// Get a list of all open EditorWindow names.
/// </summary>
/// <returns>Array of window names</returns>
public static string[] GetOpenWindowNames()
{
EditorWindow[] allWindows = Resources.FindObjectsOfTypeAll<EditorWindow>();
List<string> names = new List<string>();
foreach (EditorWindow window in allWindows)
{
if (window.titleContent != null && !string.IsNullOrEmpty(window.titleContent.text))
{
names.Add(window.titleContent.text);
}
}
return names.ToArray();
}
// Captures game rendering by reading GameView's composited RenderTexture (PlayMode only).
// Contains all cameras + Screen Space Overlay Canvas, without tab bar or borders.
// Coordinate mapping to simulate-mouse:
// sim_x = image_x / resolutionScale, sim_y = image_y / resolutionScale + YOffset
// where YOffset = Screen.height - RenderTexture.height (returned in the tuple).
public static async Task<(Texture2D? texture, int yOffset)> CaptureGameRenderingAsync(float resolutionScale, CancellationToken ct)
{
Debug.Assert(UnityEditor.EditorApplication.isPlaying, "CaptureGameRenderingAsync requires PlayMode");
// Wait for the game camera to complete at least one full render cycle after any state change
await EditorDelay.DelayFrame(2, ct);
RenderTexture rt = GameViewBridge.GetRenderTexture();
if (rt == null)
{
Debug.LogWarning("[EditorWindowCaptureUtility] GameView RenderTexture is not available");
return (null, 0);
}
int yOffset = (int)Handles.GetMainGameViewSize().y - rt.height;
// RenderTexture uses bottom-left origin; flip vertically for standard top-left image format
RenderTextureDescriptor flipDescriptor = new RenderTextureDescriptor(rt.width, rt.height, rt.format, 0);
if (QualitySettings.activeColorSpace == ColorSpace.Linear)
{
flipDescriptor.sRGB = false;
}
RenderTexture flipped = RenderTexture.GetTemporary(flipDescriptor);
Graphics.Blit(rt, flipped, new Vector2(1f, -1f), new Vector2(0f, 1f));
RenderTexture previousActive = RenderTexture.active;
RenderTexture.active = flipped;
Texture2D texture = new Texture2D(rt.width, rt.height, TextureFormat.RGB24, false);
texture.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
texture.Apply();
RenderTexture.active = previousActive;
RenderTexture.ReleaseTemporary(flipped);
if (!Mathf.Approximately(resolutionScale, 1.0f))
{
texture = ApplyResolutionScaling(texture, resolutionScale);
}
return (texture, yOffset);
}
private static Texture2D ApplyResolutionScaling(Texture2D originalTexture, float scale)
{
int newWidth = Mathf.RoundToInt(originalTexture.width * scale);
int newHeight = Mathf.RoundToInt(originalTexture.height * scale);
Texture2D scaledTexture = new Texture2D(newWidth, newHeight, originalTexture.format, false);
RenderTexture rt = RenderTexture.GetTemporary(newWidth, newHeight);
Graphics.Blit(originalTexture, rt);
RenderTexture.active = rt;
scaledTexture.ReadPixels(new Rect(0, 0, newWidth, newHeight), 0, 0);
scaledTexture.Apply();
RenderTexture.active = null;
RenderTexture.ReleaseTemporary(rt);
UnityEngine.Object.DestroyImmediate(originalTexture);
return scaledTexture;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6bc2a04d8a00241aa813a3b279e9ac7a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,26 @@
#nullable enable
using System;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
// Builds slash-separated hierarchy paths shared by screenshot metadata and UI input targeting.
internal static class GameObjectPathUtility
{
public static string GetFullPath(GameObject gameObject)
{
Debug.Assert(gameObject != null, "GameObject must not be null.");
if (gameObject == null)
{
throw new ArgumentNullException(nameof(gameObject));
}
if (gameObject.transform.parent == null)
{
return gameObject.name;
}
return GetFullPath(gameObject.transform.parent.gameObject) + "/" + gameObject.name;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e32bc3864e2bb456fb8d6d53758dd82c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,41 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Port validation utility for MCP configuration
/// </summary>
public static class McpPortValidator
{
private const int MinPort = 1;
private const int MaxPort = 65535;
private const int ReservedPortThreshold = 1024;
private static readonly int[] CommonPorts = { 80, 443, 21, 22, 23, 25, 53, 110, 143, 993, 995, 3389 };
/// <summary>
/// Validates port number and logs warnings for potential issues
/// </summary>
/// <param name="port">Port number to validate</param>
/// <param name="context">Additional context for logging (optional)</param>
/// <returns>True if port is valid, false if port is outside valid range</returns>
public static bool ValidatePort(int port, string context = "")
{
if (port <= 0 || port > MaxPort)
{
return false;
}
string contextSuffix = string.IsNullOrEmpty(context) ? "" : $" {context}";
if (port < ReservedPortThreshold)
{
return false;
}
if (System.Array.IndexOf(CommonPorts, port) != -1)
{
return false;
}
return true;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d3b722b269f1044969c6b1ed3052d092
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,371 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Utility class for detecting Node.js and npm executables.
/// Uses login shell to resolve PATH, matching the user's terminal environment.
/// </summary>
public static class NodeEnvironmentResolver
{
private const int PROCESS_TIMEOUT_MS = 5000;
public static string FindNodePath()
{
return FindNodePathAtPlatform(Application.platform);
}
public static IEnumerable<string> FindAllNodePaths()
{
return FindAllNodePathsAtPlatform(Application.platform);
}
/// <summary>
/// Finds an executable path using platform-appropriate resolution.
/// On Windows, resolves .cmd shims via 'where' command.
/// On Unix, resolves via login shell 'which' command.
/// </summary>
public static string FindExecutablePath(string executableName)
{
return FindExecutablePathAtPlatform(executableName, Application.platform);
}
public static string FindNpmPath()
{
return FindExecutablePathAtPlatform("npm", Application.platform);
}
/// <summary>
/// Sets up the PATH environment variable for a process.
/// On Unix, retrieves PATH from login shell to match the user's terminal environment.
/// </summary>
public static void SetupEnvironmentPath(ProcessStartInfo startInfo, string executablePath)
{
SetupEnvironmentPathAtPlatform(startInfo, executablePath, Application.platform);
}
internal static string FindNodePathAtPlatform(RuntimePlatform platform)
{
return FindExecutablePathAtPlatform("node", platform);
}
internal static IEnumerable<string> FindAllNodePathsAtPlatform(RuntimePlatform platform)
{
if (IsWindowsEditor(platform))
{
return FindAllExecutablePathsWindows("node");
}
return FindAllExecutablePathsUnix("node");
}
internal static string FindExecutablePathAtPlatform(string executableName, RuntimePlatform platform)
{
if (IsWindowsEditor(platform))
{
return FindExecutableWindows(executableName);
}
return FindExecutableUnix(executableName);
}
internal static void SetupEnvironmentPathAtPlatform(
ProcessStartInfo startInfo,
string executablePath,
RuntimePlatform platform)
{
if (string.IsNullOrEmpty(executablePath))
{
return;
}
string executableDir = Path.GetDirectoryName(executablePath);
string loginShellPath = GetLoginShellPathAtPlatform(platform);
string basePath = !string.IsNullOrEmpty(loginShellPath)
? loginShellPath
: (System.Environment.GetEnvironmentVariable("PATH") ?? "");
string separator = IsWindowsEditor(platform) ? ";" : ":";
if (!string.IsNullOrEmpty(executableDir))
{
startInfo.EnvironmentVariables["PATH"] = executableDir + separator + basePath;
}
else
{
startInfo.EnvironmentVariables["PATH"] = basePath;
}
}
private static string FindExecutableUnix(string executableName)
{
return TryWhichCommand(executableName);
}
private static string FindExecutableWindows(string executableName)
{
return TryWhereCommand(executableName);
}
// Only returns the login shell's which result — no hardcoded fallback paths.
// Scanning version-manager directories directly caused false positives (e.g. detecting
// an uninstalled CLI version), which was the original bug this PR fixes.
private static IEnumerable<string> FindAllExecutablePathsUnix(string executableName)
{
string whichPath = TryWhichCommand(executableName);
if (!string.IsNullOrEmpty(whichPath))
{
yield return whichPath;
}
}
private static IEnumerable<string> FindAllExecutablePathsWindows(string executableName)
{
string[] wherePaths = TryWhereCommandAll(executableName);
if (wherePaths != null)
{
foreach (string path in wherePaths)
{
if (!string.IsNullOrEmpty(path))
{
yield return path;
}
}
}
}
// Interactive login shell (-l -i) loads .zprofile and .zshrc/.bashrc, matching the user's terminal
// Markers isolate which output from shell startup banners; ExtractAbsolutePathLine filters alias text
// executableName is not shell-escaped because all callers pass hardcoded constants (YAGNI)
private static string TryWhichCommand(string executableName)
{
string shell = GetUserShell();
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = shell,
Arguments = "-l -i -c \"echo " + WHICH_START_MARKER + "; which " + executableName + "; echo " + WHICH_END_MARKER + "\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output = ExecuteAndGetOutput(startInfo);
string block = ExtractBetweenMarkers(output, WHICH_START_MARKER, WHICH_END_MARKER);
return ExtractAbsolutePathLine(block);
}
/// <summary>
/// Finds the first executable path for the given name using the Windows 'where' command.
/// Prioritizes .cmd/.exe over extensionless entries because npm generates both a bash shim
/// (extensionless) and a Windows cmd shim (.cmd); the bash shim cannot be launched via Process.Start.
/// </summary>
private static string TryWhereCommand(string executableName)
{
string[] paths = TryWhereCommandAll(executableName);
if (paths == null || paths.Length == 0)
{
return null;
}
foreach (string path in paths)
{
string extension = Path.GetExtension(path);
if (string.Equals(extension, ".cmd", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".exe", System.StringComparison.OrdinalIgnoreCase))
{
return path;
}
}
return paths[0];
}
private static string[] TryWhereCommandAll(string executableName)
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c where {executableName}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output = ExecuteAndGetOutput(startInfo);
if (!string.IsNullOrEmpty(output))
{
string[] lines = output.Split('\n');
List<string> result = new List<string>();
foreach (string line in lines)
{
string trimmed = line.Trim();
if (!string.IsNullOrEmpty(trimmed))
{
result.Add(trimmed);
}
}
return result.Count > 0 ? result.ToArray() : null;
}
return null;
}
private static string ExecuteAndGetOutput(ProcessStartInfo startInfo)
{
UnityEngine.Debug.Assert(startInfo != null, "startInfo must not be null");
UnityEngine.Debug.Assert(startInfo.RedirectStandardOutput, "RedirectStandardOutput must be true");
UnityEngine.Debug.Assert(startInfo.RedirectStandardError, "RedirectStandardError must be true");
using (Process process = Process.Start(startInfo))
{
if (process == null)
{
return null;
}
System.Text.StringBuilder stdoutBuilder = new System.Text.StringBuilder();
process.OutputDataReceived += (sender, e) =>
{
if (e.Data != null)
{
stdoutBuilder.AppendLine(e.Data);
}
};
process.ErrorDataReceived += (sender, e) => { };
process.BeginOutputReadLine();
process.BeginErrorReadLine();
if (!process.WaitForExit(PROCESS_TIMEOUT_MS))
{
// Process may exit between WaitForExit(timeout) and Kill() (TOCTOU race)
try
{
process.Kill();
process.WaitForExit(1000);
}
catch (System.InvalidOperationException)
{
UnityEngine.Debug.Log("Process already exited before Kill() was called");
}
return null;
}
// Parameterless WaitForExit flushes async output buffers
process.WaitForExit();
string output = stdoutBuilder.ToString().Trim();
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output))
{
return output;
}
}
return null;
}
private const string PATH_START_MARKER = "__PATH_START__";
private const string PATH_END_MARKER = "__PATH_END__";
private const string WHICH_START_MARKER = "__WHICH_START__";
private const string WHICH_END_MARKER = "__WHICH_END__";
// Uses markers to extract PATH value, ignoring any banner/echo output from shell startup files
private static string GetLoginShellPath()
{
return GetLoginShellPathAtPlatform(Application.platform);
}
private static string GetLoginShellPathAtPlatform(RuntimePlatform platform)
{
if (IsWindowsEditor(platform))
{
return null;
}
string shell = GetUserShell();
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = shell,
Arguments = "-l -i -c \"echo " + PATH_START_MARKER + "; printenv PATH; echo " + PATH_END_MARKER + "\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output = ExecuteAndGetOutput(startInfo);
return ExtractBetweenMarkers(output, PATH_START_MARKER, PATH_END_MARKER);
}
internal static string ExtractBetweenMarkers(string output, string startMarker, string endMarker)
{
if (string.IsNullOrEmpty(output))
{
return null;
}
int startIndex = output.IndexOf(startMarker, System.StringComparison.Ordinal);
if (startIndex < 0)
{
return null;
}
int searchFrom = startIndex + startMarker.Length;
int endIndex = output.IndexOf(endMarker, searchFrom, System.StringComparison.Ordinal);
if (endIndex < 0)
{
return null;
}
return output.Substring(startIndex + startMarker.Length, endIndex - startIndex - startMarker.Length).Trim();
}
internal static string ExtractAbsolutePathLine(string block)
{
if (string.IsNullOrEmpty(block))
{
return null;
}
string[] lines = block.Split('\n');
foreach (string rawLine in lines)
{
string line = rawLine.Trim();
if (!string.IsNullOrEmpty(line) && Path.IsPathRooted(line))
{
return line;
}
}
return null;
}
// Falls back to /bin/sh when $SHELL is unset or invalid. /bin/sh won't load
// .zshrc/.bashrc, so version-manager paths may be missed — but $SHELL being unset
// is extremely rare on macOS/Linux and there is no reliable way to detect the user's
// preferred shell without it.
private static string GetUserShell()
{
string shell = System.Environment.GetEnvironmentVariable("SHELL");
// $SHELL is external input — validate the path exists to avoid Process.Start exceptions
if (!string.IsNullOrEmpty(shell) && File.Exists(shell))
{
return shell;
}
return "/bin/sh";
}
private static bool IsWindowsEditor(RuntimePlatform platform)
{
return platform == RuntimePlatform.WindowsEditor;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 56d4a5e3232ee4af683a6a8620da9cfd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,109 @@
using System.Diagnostics;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Utility class to verify Node.js availability on the system.
/// Uses NodeEnvironmentResolver for detection with fallback strategy.
/// </summary>
public static class NodePathResolver
{
private static string _cachedNodePath;
private static bool _cacheInitialized;
private const int PROCESS_TIMEOUT_MS = 5000;
/// <summary>
/// Gets the Node.js executable path if available.
/// Returns full path to node executable, or null if not found.
/// </summary>
public static string GetNodeExecutablePath()
{
if (_cacheInitialized)
{
return _cachedNodePath;
}
_cachedNodePath = ResolveNodePath();
_cacheInitialized = true;
return _cachedNodePath;
}
/// <summary>
/// Checks if Node.js is available on the system.
/// </summary>
public static bool IsNodeAvailable()
{
return !string.IsNullOrEmpty(GetNodeExecutablePath());
}
private static string ResolveNodePath()
{
foreach (string nodePath in NodeEnvironmentResolver.FindAllNodePaths())
{
if (ValidateNodeExecutable(nodePath))
{
return nodePath;
}
}
return null;
}
private static bool ValidateNodeExecutable(string nodePath)
{
string command;
string arguments;
if (Application.platform == RuntimePlatform.WindowsEditor)
{
command = "cmd.exe";
arguments = $"/c \"{nodePath}\" -v";
}
else
{
command = nodePath;
arguments = "-v";
}
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using (Process process = Process.Start(startInfo))
{
if (process == null)
{
return false;
}
string output = process.StandardOutput.ReadToEnd().Trim();
process.StandardError.ReadToEnd();
if (!process.WaitForExit(PROCESS_TIMEOUT_MS))
{
process.Kill();
return false;
}
return process.ExitCode == 0 && output.StartsWith("v");
}
}
/// <summary>
/// Clears the cached Node.js path (for testing purposes).
/// </summary>
internal static void ClearCache()
{
_cachedNodePath = null;
_cacheInitialized = false;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 738369bf4a69841c7a6b812d3e3164d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,27 @@
using System.ComponentModel;
using System.Diagnostics;
namespace io.github.hatayama.uLoopMCP
{
public static class ProcessStartHelper
{
// NativeErrorCode 2 (ERROR_FILE_NOT_FOUND) and 3 (ERROR_PATH_NOT_FOUND) on Windows
// map to ENOENT on POSIX; other Win32Exception causes (e.g. permission denied) should
// propagate so callers fail fast instead of silently treating them as "not installed".
private const int ERROR_FILE_NOT_FOUND = 2;
private const int ERROR_PATH_NOT_FOUND = 3;
public static Process TryStart(ProcessStartInfo startInfo)
{
try
{
return Process.Start(startInfo);
}
catch (Win32Exception ex) when (ex.NativeErrorCode == ERROR_FILE_NOT_FOUND
|| ex.NativeErrorCode == ERROR_PATH_NOT_FOUND)
{
return null;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ad2429be8f297418b985fc1ab7f3a6e3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,70 @@
using System.Threading.Tasks;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Task related extension methods
/// Provides convenient methods for fire-and-forget async operations
/// </summary>
public static class TaskExtensions
{
/// <summary>
/// Fire-and-forget extension method for Task
/// Equivalent to "_ = task;" but more explicit and readable
/// </summary>
/// <param name="task">Task to fire and forget</param>
public static void Forget(this Task task)
{
if (task == null) return;
// Handle exceptions to prevent crashes
_ = HandleTaskExceptions(task);
}
/// <summary>
/// Handle task exceptions to prevent unhandled exceptions in fire-and-forget scenarios
/// </summary>
private static async Task HandleTaskExceptions(Task task)
{
try
{
await task;
}
catch (System.Exception ex)
{
// Log the exception to prevent silent failures
UnityEngine.Debug.LogException(ex);
}
}
/// <summary>
/// Fire-and-forget extension method for DelayFrameAwaitable
/// Allows writing: EditorDelay.DelayFrame().Forget()
/// </summary>
/// <param name="awaitable">DelayFrameAwaitable to fire and forget</param>
public static void Forget(this DelayFrameAwaitable awaitable)
{
// Convert to Task and discard using consistent pattern
ConvertToTask(awaitable).Forget();
}
/// <summary>
/// Convert DelayFrameAwaitable to Task for fire-and-forget operations
/// Includes exception handling to prevent unobserved task exceptions in fire-and-forget scenarios
/// </summary>
private static async Task ConvertToTask(DelayFrameAwaitable awaitable)
{
try
{
await awaitable;
}
catch (System.Exception ex)
{
// Log the exception to prevent silent failures
UnityEngine.Debug.LogException(ex);
// Intentionally swallow exceptions in fire-and-forget scenarios
// to prevent unobserved task exceptions from crashing the application
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 87b726546d9b54bc299c597c41095173
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,119 @@
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Timer-based asynchronous delay for Unity Editor
/// Provides async/await support for time-based delays that work regardless of Unity Editor state
/// </summary>
public static class TimerDelay
{
/// <summary>
/// Wait for the specified number of milliseconds
/// </summary>
/// <param name="milliseconds">Milliseconds to wait</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Awaitable task</returns>
public static Task Wait(int milliseconds, CancellationToken cancellationToken = default)
{
if (milliseconds <= 0)
{
return Task.CompletedTask;
}
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
Timer timer = null;
timer = new Timer(_ =>
{
timer?.Dispose();
timer = null;
if (!cancellationToken.IsCancellationRequested)
{
tcs.SetResult(true);
}
else
{
tcs.SetCanceled();
}
}, null, milliseconds, Timeout.Infinite);
// Handle cancellation
if (cancellationToken.CanBeCanceled)
{
cancellationToken.Register(() =>
{
timer?.Dispose();
timer = null;
tcs.TrySetCanceled();
});
}
return tcs.Task;
}
/// <summary>
/// Wait for the specified number of milliseconds, then execute action on main thread
/// </summary>
/// <param name="milliseconds">Milliseconds to wait</param>
/// <param name="action">Action to execute on main thread</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Awaitable task</returns>
public static Task WaitThenExecuteOnMainThread(int milliseconds, Action action, CancellationToken cancellationToken = default)
{
if (action == null)
throw new ArgumentNullException(nameof(action));
if (milliseconds <= 0)
{
EditorApplication.delayCall += () => action();
return Task.CompletedTask;
}
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
Timer timer = null;
timer = new Timer(_ =>
{
timer?.Dispose();
timer = null;
if (!cancellationToken.IsCancellationRequested)
{
EditorApplication.delayCall += () =>
{
try
{
action();
tcs.SetResult(true);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
};
}
else
{
tcs.SetCanceled();
}
}, null, milliseconds, Timeout.Infinite);
// Handle cancellation
if (cancellationToken.CanBeCanceled)
{
cancellationToken.Register(() =>
{
timer?.Dispose();
timer = null;
tcs.TrySetCanceled();
});
}
return tcs.Task;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 00ccb99203e9d44039500f42349704b5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,684 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace io.github.hatayama.uLoopMCP
{
// Creates a temporary Screen Space Overlay Canvas that draws bounding boxes and labels
// over interactive UI elements. The overlay is captured by GameView's m_RenderTexture
// (OnGUI-based overlays are NOT included in the RT).
public static class UIElementAnnotator
{
private const int OVERLAY_SORT_ORDER = 32767;
private const int LABEL_FONT_SIZE = 20;
private const float OUTPUT_BORDER_NEUTRAL_THICKNESS = 2f;
private const float OUTPUT_BORDER_COLOR_THICKNESS = 4f;
private const int LABEL_PADDING_H = 6;
private const int LABEL_PADDING_V = 3;
private const float LABEL_DARK_TEXT_LUMINANCE_THRESHOLD = 0.62f;
private const float OUTPUT_LABEL_OUTLINE_DISTANCE = 2f;
private const float OUTPUT_LABEL_TO_BORDER_GAP = 4f;
private const string INTERACTION_CLICK = "Click";
private const string INTERACTION_DRAG = "Drag";
private const string INTERACTION_DROP = "Drop";
private const string INTERACTION_TEXT = "Text";
private const string DISPLAY_LABEL_SEPARATOR = " / ";
// Label-based colors separate dense controls where many elements share the same UI type.
private static readonly Color[] ANNOTATION_COLORS =
{
new Color(1f, 0.35f, 0f, 0.95f),
new Color(0f, 0.9f, 1f, 0.95f),
new Color(1f, 0.15f, 0.65f, 0.95f),
new Color(1f, 0.9f, 0f, 0.95f),
new Color(0.2f, 1f, 0.35f, 0.95f),
new Color(0.65f, 0.45f, 1f, 0.95f),
new Color(1f, 1f, 1f, 0.95f),
new Color(0.15f, 0.55f, 1f, 0.95f),
new Color(1f, 0.55f, 0.75f, 0.95f),
new Color(0.45f, 1f, 0.8f, 0.95f),
new Color(0.9f, 0.45f, 0.15f, 0.95f),
new Color(0.45f, 0.85f, 0.1f, 0.95f),
new Color(0.95f, 0.2f, 0.2f, 0.95f),
new Color(0.55f, 0.7f, 1f, 0.95f),
new Color(0.95f, 0.95f, 0.45f, 0.95f),
new Color(0.85f, 0.55f, 1f, 0.95f)
};
private static readonly Color FALLBACK_COLOR = new Color(1f, 1f, 0f, 0.9f);
private static readonly Color DARK_CONTRAST_COLOR = new Color(0f, 0f, 0f, 0.95f);
private static readonly Color LIGHT_CONTRAST_COLOR = new Color(1f, 1f, 1f, 0.95f);
public static List<UIElementInfo> CollectInteractiveElements()
{
List<UIElementInfo> elements = new List<UIElementInfo>();
HashSet<GameObject> processedObjects = new HashSet<GameObject>();
CollectSelectables(elements, processedObjects);
CollectEventHandlers(elements, processedObjects);
return elements;
}
// Sorts by z-order (frontmost = A) so the AI can reason about occlusion from label order alone
public static void AssignLabels(List<UIElementInfo> elements)
{
elements.Sort((a, b) =>
{
int sortOrderCompare = b.SortingOrder.CompareTo(a.SortingOrder);
if (sortOrderCompare != 0) return sortOrderCompare;
return b.SiblingIndex.CompareTo(a.SiblingIndex);
});
for (int i = 0; i < elements.Count; i++)
{
elements[i].Label = GenerateLabel(i);
}
}
private static string GenerateLabel(int index)
{
string label = "";
int remaining = index;
do
{
label = (char)('A' + remaining % 26) + label;
remaining = remaining / 26 - 1;
} while (remaining >= 0);
return label;
}
public static void ConvertToSimCoordinates(List<UIElementInfo> elements, int screenHeight)
{
foreach (UIElementInfo element in elements)
{
element.SimY = screenHeight - element.SimY;
float originalMinY = element.BoundsMinY;
element.BoundsMinY = screenHeight - element.BoundsMaxY;
element.BoundsMaxY = screenHeight - originalMinY;
}
}
public static GameObject CreateAnnotationOverlay(List<UIElementInfo> elements, float outputResolutionScale)
{
AnnotationBorderMetrics borderMetrics = CalculateAnnotationBorderMetrics(outputResolutionScale);
GameObject root = new GameObject("__UIAnnotation__");
root.hideFlags = HideFlags.HideAndDontSave;
Canvas canvas = root.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = OVERLAY_SORT_ORDER;
Font font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
foreach (UIElementInfo element in elements)
{
CreateAnnotationForElement(root.transform, element, font, borderMetrics);
}
return root;
}
public static void DestroyAnnotationOverlay(GameObject overlay)
{
if (overlay != null)
{
Object.DestroyImmediate(overlay);
}
}
private static void CollectSelectables(List<UIElementInfo> elements, HashSet<GameObject> processedObjects)
{
Selectable[] selectables = Selectable.allSelectablesArray;
foreach (Selectable selectable in selectables)
{
if (!selectable.IsInteractable() || !selectable.isActiveAndEnabled)
{
continue;
}
processedObjects.Add(selectable.gameObject);
string type = ClassifySelectable(selectable);
AddElementInfo(elements, selectable.gameObject, selectable.name, type);
}
}
// Collects non-Selectable MonoBehaviours that implement pointer/drag event interfaces.
// Priority: IDragHandler > IDropHandler > IPointerClickHandler > IPointerDownHandler
private static void CollectEventHandlers(List<UIElementInfo> elements, HashSet<GameObject> processedObjects)
{
MonoBehaviour[] allBehaviours = Object.FindObjectsOfType<MonoBehaviour>();
foreach (MonoBehaviour behaviour in allBehaviours)
{
if (!behaviour.isActiveAndEnabled)
{
continue;
}
if (processedObjects.Contains(behaviour.gameObject))
{
continue;
}
string type = ClassifyEventHandler(behaviour);
if (type == null)
{
continue;
}
processedObjects.Add(behaviour.gameObject);
AddElementInfo(elements, behaviour.gameObject, behaviour.name, type);
}
}
private static string ClassifyEventHandler(MonoBehaviour behaviour)
{
if (behaviour is IDragHandler) return "Draggable";
if (behaviour is IDropHandler) return "DropTarget";
if (behaviour is IPointerClickHandler) return "Button";
if (behaviour is IPointerDownHandler) return "Button";
return null;
}
private static string ClassifySelectable(Selectable selectable)
{
if (selectable is Button) return "Button";
if (selectable is Toggle) return "Toggle";
if (selectable is Slider) return "Slider";
if (selectable is Dropdown) return "Dropdown";
if (selectable is InputField) return "InputField";
if (selectable is Scrollbar) return "Scrollbar";
if (selectable is IDragHandler) return "Draggable";
if (selectable is IDropHandler) return "DropTarget";
return "Selectable";
}
// Reusable buffers to avoid per-element allocations in AddElementInfo → GetScreenCorners
private static readonly Vector3[] SharedWorldCorners = new Vector3[4];
private static readonly Vector2[] SharedScreenCorners = new Vector2[4];
private static readonly List<RaycastResult> SharedRaycastResults = new List<RaycastResult>();
private static PointerEventData SharedPointerEventData;
private static EventSystem SharedPointerEventSystem;
private static void AddElementInfo(List<UIElementInfo> elements, GameObject go, string name, string type)
{
RectTransform rectTransform = go.GetComponent<RectTransform>();
if (rectTransform == null)
{
return;
}
Canvas canvas = go.GetComponentInParent<Canvas>();
if (canvas == null)
{
return;
}
// EventSystem.RaycastAll only hits elements under a Canvas with an enabled GraphicRaycaster
if (!HasActiveGraphicRaycaster(canvas))
{
return;
}
if (!GetScreenCorners(rectTransform, canvas))
{
return;
}
float minX = Mathf.Min(SharedScreenCorners[0].x, SharedScreenCorners[1].x, SharedScreenCorners[2].x, SharedScreenCorners[3].x);
float maxX = Mathf.Max(SharedScreenCorners[0].x, SharedScreenCorners[1].x, SharedScreenCorners[2].x, SharedScreenCorners[3].x);
float minY = Mathf.Min(SharedScreenCorners[0].y, SharedScreenCorners[1].y, SharedScreenCorners[2].y, SharedScreenCorners[3].y);
float maxY = Mathf.Max(SharedScreenCorners[0].y, SharedScreenCorners[1].y, SharedScreenCorners[2].y, SharedScreenCorners[3].y);
float centerX = (minX + maxX) / 2f;
float centerY = (minY + maxY) / 2f;
if (!IsRaycastReachable(go, centerX, centerY))
{
return;
}
elements.Add(new UIElementInfo
{
Name = name,
Path = GameObjectPathUtility.GetFullPath(go),
Type = type,
Interaction = GetInteractionForType(type),
SimX = centerX,
SimY = centerY,
BoundsMinX = minX,
BoundsMinY = minY,
BoundsMaxX = maxX,
BoundsMaxY = maxY,
SortingOrder = canvas.sortingOrder,
SiblingIndex = go.transform.GetSiblingIndex()
});
}
private static bool HasActiveGraphicRaycaster(Canvas canvas)
{
GraphicRaycaster raycaster = canvas.GetComponent<GraphicRaycaster>();
return raycaster != null && raycaster.isActiveAndEnabled;
}
// simulate-mouse uses EventSystem.RaycastAll, so only advertise elements whose
// center point is actually hittable. Skips the check when no EventSystem exists
// (e.g. annotation-only scenes without interaction).
private static bool IsRaycastReachable(GameObject go, float centerX, float centerY)
{
EventSystem eventSystem = EventSystem.current;
if (eventSystem == null)
{
return true;
}
if (SharedPointerEventData == null || SharedPointerEventSystem != eventSystem)
{
SharedPointerEventData = new PointerEventData(eventSystem);
SharedPointerEventSystem = eventSystem;
}
SharedPointerEventData.position = new Vector2(centerX, centerY);
SharedRaycastResults.Clear();
eventSystem.RaycastAll(SharedPointerEventData, SharedRaycastResults);
Transform targetTransform = go.transform;
foreach (RaycastResult raycastResult in SharedRaycastResults)
{
Transform hitTransform = raycastResult.gameObject.transform;
if (hitTransform == targetTransform || hitTransform.IsChildOf(targetTransform))
{
return true;
}
}
// EventSystem clips at Screen.width/height which can be smaller than the
// Canvas layout space (Game view target resolution). Check Canvas space directly.
RectTransform rectTransform = go.GetComponent<RectTransform>();
if (rectTransform != null)
{
return RectTransformUtility.RectangleContainsScreenPoint(
rectTransform, new Vector2(centerX, centerY), null);
}
return false;
}
// Writes 4 corners into SharedScreenCorners in screen pixel coordinates (bottom-left origin).
// For ScreenSpaceOverlay: world corners == screen pixels.
// For Camera/WorldSpace: projects through the canvas camera.
// Returns false when the canvas camera is unavailable for non-overlay canvases.
private static bool GetScreenCorners(RectTransform rectTransform, Canvas canvas)
{
rectTransform.GetWorldCorners(SharedWorldCorners);
if (canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
for (int i = 0; i < 4; i++)
{
SharedScreenCorners[i] = new Vector2(SharedWorldCorners[i].x, SharedWorldCorners[i].y);
}
}
else
{
// Prefer the rendering canvas's camera; fall back to root canvas, then Camera.main
Camera cam = canvas.worldCamera;
if (cam == null)
{
Canvas rootCanvas = canvas.rootCanvas;
if (rootCanvas != canvas)
{
cam = rootCanvas.worldCamera;
}
}
if (cam == null)
{
cam = Camera.main;
}
if (cam == null)
{
return false;
}
for (int i = 0; i < 4; i++)
{
SharedScreenCorners[i] = RectTransformUtility.WorldToScreenPoint(cam, SharedWorldCorners[i]);
}
}
return true;
}
private static void CreateAnnotationForElement(
Transform parent,
UIElementInfo element,
Font font,
AnnotationBorderMetrics borderMetrics)
{
float screenMinX = element.BoundsMinX;
float screenMaxX = element.BoundsMaxX;
float screenMinY = element.BoundsMinY;
float screenMaxY = element.BoundsMaxY;
Color color = GetAnnotationColorForElement(element);
Color contrastColor = GetContrastingTextColor(color);
AnnotationBorderColors borderColors = GetAnnotationBorderColors(color);
CreateBorder(
parent,
"LightOuter",
screenMinX - borderMetrics.OuterOffset,
screenMinY - borderMetrics.OuterOffset,
screenMaxX + borderMetrics.OuterOffset,
screenMaxY + borderMetrics.OuterOffset,
borderMetrics.NeutralThickness,
borderColors.Outer);
CreateBorder(
parent,
"ColorMiddle",
screenMinX - borderMetrics.ColorOffset,
screenMinY - borderMetrics.ColorOffset,
screenMaxX + borderMetrics.ColorOffset,
screenMaxY + borderMetrics.ColorOffset,
borderMetrics.ColorThickness,
borderColors.Middle);
CreateBorder(parent, "DarkInner", screenMinX, screenMinY, screenMaxX, screenMaxY, borderMetrics.NeutralThickness, borderColors.Inner);
string labelText = CreateDisplayLabel(element);
CreateLabel(
parent,
labelText,
screenMinX,
screenMaxY + borderMetrics.OuterOffset + borderMetrics.LabelOutlineDistance + borderMetrics.LabelToBorderGap,
color,
contrastColor,
font,
borderMetrics.LabelOutlineDistance);
}
private static void CreateBorder(
Transform parent, string name,
float minX, float minY, float maxX, float maxY,
float thickness, Color color)
{
BorderEdgeRects borderEdgeRects = CalculateBorderEdgeRects(minX, minY, maxX, maxY, thickness);
CreateBorderEdge(parent, $"{name}_Top", borderEdgeRects.Top, color);
CreateBorderEdge(parent, $"{name}_Bottom", borderEdgeRects.Bottom, color);
CreateBorderEdge(parent, $"{name}_Left", borderEdgeRects.Left, color);
CreateBorderEdge(parent, $"{name}_Right", borderEdgeRects.Right, color);
}
internal static BorderEdgeRects CalculateBorderEdgeRects(
float minX, float minY, float maxX, float maxY, float thickness)
{
Debug.Assert(maxX >= minX, "maxX must not be smaller than minX.");
Debug.Assert(maxY >= minY, "maxY must not be smaller than minY.");
Debug.Assert(thickness >= 0f, "thickness must not be negative.");
float boxWidth = maxX - minX;
float boxHeight = maxY - minY;
float verticalEdgeHeight = Mathf.Max(0f, boxHeight - thickness * 2f);
return new BorderEdgeRects(
new Rect(minX, maxY - thickness, boxWidth, thickness),
new Rect(minX, minY, boxWidth, thickness),
new Rect(minX, minY + thickness, thickness, verticalEdgeHeight),
new Rect(maxX - thickness, minY + thickness, thickness, verticalEdgeHeight));
}
private static void CreateBorderEdge(
Transform parent, string name,
Rect rect,
Color color)
{
GameObject edgeGo = new GameObject($"Border_{name}");
edgeGo.hideFlags = HideFlags.HideAndDontSave;
edgeGo.transform.SetParent(parent, false);
RectTransform rt = edgeGo.AddComponent<RectTransform>();
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.zero;
rt.pivot = Vector2.zero;
rt.anchoredPosition = new Vector2(rect.x, rect.y);
rt.sizeDelta = new Vector2(rect.width, rect.height);
Image image = edgeGo.AddComponent<Image>();
image.color = color;
image.raycastTarget = false;
}
private static void CreateLabel(
Transform parent, string text,
float x, float y,
Color backgroundColor, Color textColor, Font font, float outlineDistance)
{
GameObject bgGo = new GameObject("LabelBg");
bgGo.hideFlags = HideFlags.HideAndDontSave;
bgGo.transform.SetParent(parent, false);
RectTransform bgRt = bgGo.AddComponent<RectTransform>();
bgRt.anchorMin = Vector2.zero;
bgRt.anchorMax = Vector2.zero;
bgRt.pivot = new Vector2(0f, 0f);
bgRt.anchoredPosition = new Vector2(x, y);
Image bgImage = bgGo.AddComponent<Image>();
bgImage.color = backgroundColor;
bgImage.raycastTarget = false;
Outline bgOutline = bgGo.AddComponent<Outline>();
bgOutline.effectColor = GetContrastPartnerColor(backgroundColor);
bgOutline.effectDistance = new Vector2(outlineDistance, -outlineDistance);
ContentSizeFitter fitter = bgGo.AddComponent<ContentSizeFitter>();
fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
HorizontalLayoutGroup layout = bgGo.AddComponent<HorizontalLayoutGroup>();
layout.padding = new RectOffset(LABEL_PADDING_H, LABEL_PADDING_H, LABEL_PADDING_V, LABEL_PADDING_V);
layout.childAlignment = TextAnchor.MiddleLeft;
GameObject textGo = new GameObject("LabelText");
textGo.hideFlags = HideFlags.HideAndDontSave;
textGo.transform.SetParent(bgGo.transform, false);
textGo.AddComponent<RectTransform>();
Text labelText = textGo.AddComponent<Text>();
labelText.text = text;
labelText.font = font;
labelText.fontSize = LABEL_FONT_SIZE;
labelText.fontStyle = FontStyle.Bold;
labelText.color = textColor;
labelText.alignment = TextAnchor.MiddleLeft;
labelText.horizontalOverflow = HorizontalWrapMode.Overflow;
labelText.verticalOverflow = VerticalWrapMode.Overflow;
labelText.raycastTarget = false;
}
internal static Color GetAnnotationColorForElement(UIElementInfo element)
{
Debug.Assert(element != null, "UIElementInfo must not be null.");
int labelIndex = GetLabelIndex(element.Label);
if (labelIndex < 0)
{
return FALLBACK_COLOR;
}
return ANNOTATION_COLORS[labelIndex % ANNOTATION_COLORS.Length];
}
internal static Color GetContrastingTextColor(Color backgroundColor)
{
float luminance = CalculateLuminance(backgroundColor);
if (luminance >= LABEL_DARK_TEXT_LUMINANCE_THRESHOLD)
{
return DARK_CONTRAST_COLOR;
}
return LIGHT_CONTRAST_COLOR;
}
internal static Color GetContrastPartnerColor(Color color)
{
Color readableColor = GetContrastingTextColor(color);
if (readableColor == DARK_CONTRAST_COLOR)
{
return LIGHT_CONTRAST_COLOR;
}
return DARK_CONTRAST_COLOR;
}
internal static AnnotationBorderColors GetAnnotationBorderColors(Color annotationColor)
{
return new AnnotationBorderColors(DARK_CONTRAST_COLOR, annotationColor, LIGHT_CONTRAST_COLOR);
}
internal static AnnotationBorderMetrics CalculateAnnotationBorderMetrics(float outputResolutionScale)
{
Debug.Assert(outputResolutionScale > 0f, "Output resolution scale must be positive.");
float neutralThickness = OUTPUT_BORDER_NEUTRAL_THICKNESS / outputResolutionScale;
float colorThickness = OUTPUT_BORDER_COLOR_THICKNESS / outputResolutionScale;
float labelOutlineDistance = OUTPUT_LABEL_OUTLINE_DISTANCE / outputResolutionScale;
float labelToBorderGap = OUTPUT_LABEL_TO_BORDER_GAP / outputResolutionScale;
return new AnnotationBorderMetrics(
neutralThickness,
colorThickness,
colorThickness,
colorThickness + neutralThickness,
labelOutlineDistance,
labelToBorderGap);
}
internal static string GetInteractionForType(string type)
{
if (type == "Slider" || type == "Scrollbar" || type == "Draggable")
{
return INTERACTION_DRAG;
}
if (type == "DropTarget")
{
return INTERACTION_DROP;
}
if (type == "InputField")
{
return INTERACTION_TEXT;
}
return INTERACTION_CLICK;
}
internal static string CreateDisplayLabel(UIElementInfo element)
{
Debug.Assert(element != null, "UIElementInfo must not be null.");
string interaction = element.Interaction;
if (string.IsNullOrEmpty(interaction))
{
interaction = GetInteractionForType(element.Type);
}
if (string.IsNullOrEmpty(element.Label))
{
return interaction.ToUpperInvariant();
}
return $"{element.Label}{DISPLAY_LABEL_SEPARATOR}{interaction.ToUpperInvariant()}";
}
private static float CalculateLuminance(Color color)
{
return color.r * 0.299f + color.g * 0.587f + color.b * 0.114f;
}
private static int GetLabelIndex(string label)
{
if (string.IsNullOrEmpty(label))
{
return -1;
}
int index = 0;
for (int i = 0; i < label.Length; i++)
{
char labelCharacter = label[i];
if (labelCharacter < 'A' || labelCharacter > 'Z')
{
return -1;
}
index = index * 26 + labelCharacter - 'A' + 1;
}
return index - 1;
}
internal readonly struct BorderEdgeRects
{
public readonly Rect Top;
public readonly Rect Bottom;
public readonly Rect Left;
public readonly Rect Right;
public BorderEdgeRects(Rect top, Rect bottom, Rect left, Rect right)
{
Top = top;
Bottom = bottom;
Left = left;
Right = right;
}
}
internal readonly struct AnnotationBorderColors
{
public readonly Color Inner;
public readonly Color Middle;
public readonly Color Outer;
public AnnotationBorderColors(Color inner, Color middle, Color outer)
{
Inner = inner;
Middle = middle;
Outer = outer;
}
}
internal readonly struct AnnotationBorderMetrics
{
public readonly float NeutralThickness;
public readonly float ColorThickness;
public readonly float ColorOffset;
public readonly float OuterOffset;
public readonly float LabelOutlineDistance;
public readonly float LabelToBorderGap;
public AnnotationBorderMetrics(
float neutralThickness,
float colorThickness,
float colorOffset,
float outerOffset,
float labelOutlineDistance,
float labelToBorderGap)
{
NeutralThickness = neutralThickness;
ColorThickness = colorThickness;
ColorOffset = colorOffset;
OuterOffset = outerOffset;
LabelOutlineDistance = labelOutlineDistance;
LabelToBorderGap = labelToBorderGap;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 12fde7d4deff64366a82982b7d4e9fe5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: