chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8736979d7836d4547bbbf5abc9209c3c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d43e1f6aceb664627af14cbfdd451ef5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6bc2a04d8a00241aa813a3b279e9ac7a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e32bc3864e2bb456fb8d6d53758dd82c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d3b722b269f1044969c6b1ed3052d092
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 56d4a5e3232ee4af683a6a8620da9cfd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 738369bf4a69841c7a6b812d3e3164d7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad2429be8f297418b985fc1ab7f3a6e3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87b726546d9b54bc299c597c41095173
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00ccb99203e9d44039500f42349704b5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12fde7d4deff64366a82982b7d4e9fe5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user