Files
daluandou/.codely.packages/cn.tuanjie.codely.bridge@1.0.85-785dea6a/Editor/Bridge/Tools/ExecuteMenuItem.cs
T
huadexuan 53d95802e5 ```
feat(gameplay): 更新单位属性和材质配置

- 调整多个单位的基础生命值和攻击力数值平衡
- 修改CoreBattleGround材质的纹理缩放比例
- 更新地砖材质颜色为白色
- 优化纹理资源设置,调整最大纹理尺寸并添加PlayableAds平台支持
- 修复弓箭手技能目标查找逻辑,改用统一的敌人检测方法
- 为水晶矿添加中立阵营设置和光球视觉效果同步
- 调整预制体中的全局缩放参数
- 为狼人和石巨人添加NavMeshAgent组件支持
- 优化水晶矿血条显示位置计算方式
```
2026-09-20 21:52:31 +08:00

444 lines
20 KiB
C#

using System;
using System.Collections.Generic; // Added for HashSet
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers; // For Response class
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// Handles executing Unity Editor menu items by path.
/// </summary>
public static class ExecuteMenuItem
{
// Basic blacklist to prevent accidental execution of potentially disruptive menu items.
// This can be expanded based on needs.
private static readonly HashSet<string> _menuPathBlacklist = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
"File/Quit",
// Add other potentially dangerous items like "Edit/Preferences...", "File/Build Settings..." if needed
};
private const string TmpEssentialsImportMenuPath =
"Window/TextMeshPro/Import TMP Essential Resources";
/// <summary>
/// Main handler for executing menu items or getting available ones.
/// </summary>
private static readonly Dictionary<string, Func<JObject, object>> ActionHandlers =
new Dictionary<string, Func<JObject, object>>
{
{ "execute", ExecuteItem },
{ "get_available_menus", GetAvailableMenus },
};
public static object HandleCommand(JObject @params)
=> ActionRouter.Route(@params, ActionHandlers, defaultAction: "execute");
/// <summary>
/// Executes a specific menu item.
/// </summary>
private static object ExecuteItem(JObject @params)
{
// Try both naming conventions: snake_case and camelCase
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
// Optional future param retained for API compatibility; not used in synchronous mode
// int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject<int>() ?? 2000));
// string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements.
// JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem).
if (string.IsNullOrWhiteSpace(menuPath))
{
return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty.");
}
// Validate against blacklist
if (_menuPathBlacklist.Contains(menuPath))
{
return Response.Error(
$"Execution of menu item '{menuPath}' is blocked for safety reasons."
);
}
// TODO: Implement alias lookup here if needed (Map alias to actual menuPath).
// if (!string.IsNullOrEmpty(alias)) { menuPath = LookupAlias(alias); if(menuPath == null) return Response.Error(...); }
// TODO: Handle parameters ('parameters' object) if a viable method is found.
// This is complex as EditorApplication.ExecuteMenuItem doesn't take arguments directly.
// It might require finding the underlying EditorWindow or command if parameters are needed.
try
{
// Trace incoming execute requests (debug-gated)
CodelyLogger.Verbose($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'");
if (string.Equals(menuPath, TmpEssentialsImportMenuPath, StringComparison.Ordinal))
{
TmpEssentialsAutoImporter.ScheduleImport();
return Response.Success(
$"Scheduled TMP essential resources import for menu item: '{menuPath}'",
new { executed = true, menuPath }
);
}
// Execute synchronously. This code runs on the Editor main thread in our bridge path.
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
if (executed)
{
// Success trace (debug-gated)
CodelyLogger.Verbose($"[ExecuteMenuItem] Executed successfully: '{menuPath}'");
return Response.Success(
$"Executed menu item: '{menuPath}'",
new { executed = true, menuPath }
);
}
CodelyLogger.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'");
return Response.Error(
$"Failed to execute menu item (not found or disabled): '{menuPath}'",
new { executed = false, menuPath }
);
}
catch (Exception e)
{
CodelyLogger.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}");
return Response.Error($"Error executing menu item '{menuPath}': {e.Message}");
}
}
// TODO: Add helper for alias lookup if implementing aliases.
// private static string LookupAlias(string alias) { ... return actualMenuPath or null ... }
// --- get_available_menus support ---------------------------------------------------
// Standard built-in top-level Unity menus. These are native (C++-defined) and so are
// invisible to TypeCache; we keep them as a baseline. Custom top-level menus such as
// "AI" are added dynamically in EnumerateTopLevelSeeds(). Querying a menu that does not
// exist simply returns no items, so over-seeding is harmless.
private static readonly string[] _topLevelMenus =
{
"File", "Edit", "Assets", "GameObject", "Component", "Window", "Help", "Tools", "Mobile"
};
// Cached reflection handles for UnityEditor.Menu.GetMenuItems(string, bool, bool).
private static bool _menuReflectionResolved;
private static MethodInfo _getMenuItemsMethod;
// 'path' PropertyInfo cached per concrete item runtime type. Keying by type keeps the
// lookup cheap while staying correct if GetMenuItems ever returns more than one item
// type (e.g. a separator type alongside regular items). A null value is cached too,
// so a type that genuinely lacks a 'path' property is not re-probed every item.
private static readonly Dictionary<Type, PropertyInfo> _pathPropByType =
new Dictionary<Type, PropertyInfo>();
/// <summary>
/// Enumerates available Editor menu item paths.
/// Optional params: 'menu_path'/'menuPath' to scope to a subtree (default: all),
/// 'filter'/'search' for a case-insensitive substring match, and
/// 'include_separators'/'includeSeparators' (default: false).
/// </summary>
private static object GetAvailableMenus(JObject @params)
{
string rootPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString() ?? string.Empty;
rootPath = (rootPath ?? string.Empty).Trim().TrimEnd('/');
string filter = @params["filter"]?.ToString() ?? @params["search"]?.ToString();
bool includeSeparators = @params["include_separators"]?.ToObject<bool>()
?? @params["includeSeparators"]?.ToObject<bool>() ?? false;
var paths = new HashSet<string>(StringComparer.Ordinal);
// Primary source: native UnityEditor.Menu.GetMenuItems via reflection. This
// captures built-in (C++-defined) menu items as well as script-defined ones.
string reflectionError = TryCollectMenuPathsViaReflection(rootPath, includeSeparators, paths);
// Supplement with [MenuItem]-attributed methods discovered via TypeCache. Merged
// unconditionally into the same set so duplicates collapse, ensuring script-defined
// items are always present even when the reflection API only returned a partial
// subtree (e.g. immediate children) or was unavailable entirely.
CollectMenuPathsViaTypeCache(rootPath, paths);
if (paths.Count == 0 && reflectionError != null)
{
return Response.Error($"Unable to enumerate menu items: {reflectionError}");
}
IEnumerable<string> result = paths;
if (!string.IsNullOrEmpty(filter))
{
result = result.Where(p => p.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0);
}
var sorted = result.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList();
CodelyLogger.Verbose(
$"[ExecuteMenuItem] get_available_menus -> {sorted.Count} item(s) (root='{rootPath}', filter='{filter}').");
return Response.Success($"Found {sorted.Count} menu item(s).", sorted);
}
/// <summary>
/// Walks the Editor menu tree using the internal UnityEditor.Menu.GetMenuItems API.
/// Returns null on success, or an error message if the API could not be used.
/// </summary>
private static string TryCollectMenuPathsViaReflection(
string rootPath, bool includeSeparators, HashSet<string> outPaths)
{
EnsureMenuReflection();
if (_getMenuItemsMethod == null)
{
return "UnityEditor.Menu.GetMenuItems(string, bool, bool) was not found on this Unity version.";
}
try
{
var expanded = new HashSet<string>(StringComparer.Ordinal);
var queue = new Queue<string>();
queue.Enqueue(rootPath ?? string.Empty);
// When listing everything, also seed the top-level menus in case the
// empty-root query is not honored on this Unity version.
if (string.IsNullOrEmpty(rootPath))
{
foreach (string top in EnumerateTopLevelSeeds())
{
queue.Enqueue(top);
}
}
// Guard against pathological loops; real menu trees are far smaller.
int guard = 0;
const int maxExpansions = 20000;
while (queue.Count > 0 && guard++ < maxExpansions)
{
string q = queue.Dequeue();
if (!expanded.Add(q)) continue;
string[] childPaths = InvokeGetMenuItems(q, includeSeparators);
if (childPaths == null || childPaths.Length == 0) continue;
// GetMenuItems may return either the full subtree (deep paths) or only the
// immediate children of 'q'. Detect which by checking relative depth.
string prefix = q.Length == 0 ? string.Empty : q + "/";
bool returnedSubtree = false;
foreach (string p in childPaths)
{
if (string.IsNullOrEmpty(p)) continue;
outPaths.Add(p);
string rel = (prefix.Length > 0 && p.StartsWith(prefix, StringComparison.Ordinal))
? p.Substring(prefix.Length)
: p;
if (rel.IndexOf('/') >= 0) returnedSubtree = true;
}
// Only walk deeper when we got immediate children; otherwise everything
// beneath 'q' is already collected.
if (!returnedSubtree)
{
foreach (string p in childPaths)
{
if (!string.IsNullOrEmpty(p)) queue.Enqueue(p);
}
}
}
return null;
}
catch (Exception e)
{
CodelyLogger.LogWarning($"[ExecuteMenuItem] Reflection enumeration failed: {e.Message}");
return e.Message;
}
}
/// <summary>
/// Invokes the cached GetMenuItems method for a single menu path and returns the
/// reported item paths. Returns an empty array if nothing is found.
/// </summary>
private static string[] InvokeGetMenuItems(string menuPath, bool includeSeparators)
{
System.Array items;
try
{
items = _getMenuItemsMethod.Invoke(
null,
new object[] { menuPath ?? string.Empty, includeSeparators, false }
) as System.Array;
}
catch (Exception)
{
return null;
}
if (items == null || items.Length == 0) return Array.Empty<string>();
var result = new List<string>(items.Length);
for (int i = 0; i < items.Length; i++)
{
object item = items.GetValue(i);
if (item == null) continue;
Type itemType = item.GetType();
if (!_pathPropByType.TryGetValue(itemType, out PropertyInfo pathProp))
{
pathProp = itemType.GetProperty(
"path",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic
);
_pathPropByType[itemType] = pathProp;
}
string path = pathProp?.GetValue(item) as string;
if (!string.IsNullOrEmpty(path)) result.Add(path);
}
return result.ToArray();
}
/// <summary>
/// Returns the set of top-level menu names used to seed enumeration when the
/// empty-root query is not honored. Combines the standard built-in menus (native,
/// hence invisible to TypeCache) with the first path segment of every [MenuItem]
/// discovered via TypeCache, so custom top-level menus (e.g. "AI/...") are not missed.
/// </summary>
private static IEnumerable<string> EnumerateTopLevelSeeds()
{
var seeds = new HashSet<string>(_topLevelMenus, StringComparer.Ordinal);
try
{
foreach (MethodInfo method in TypeCache.GetMethodsWithAttribute<MenuItem>())
{
foreach (MenuItem attr in method.GetCustomAttributes(typeof(MenuItem), false))
{
if (attr == null || attr.validate) continue;
string menuPath = StripMenuShortcut(attr.menuItem);
if (string.IsNullOrEmpty(menuPath)) continue;
if (menuPath.StartsWith("CONTEXT/", StringComparison.OrdinalIgnoreCase)) continue;
int slash = menuPath.IndexOf('/');
string top = slash >= 0 ? menuPath.Substring(0, slash) : menuPath;
if (!string.IsNullOrEmpty(top)) seeds.Add(top);
}
}
}
catch (Exception e)
{
CodelyLogger.LogWarning($"[ExecuteMenuItem] Failed to derive dynamic menu seeds: {e.Message}");
}
return seeds;
}
/// <summary>
/// Resolves and caches the UnityEditor.Menu.GetMenuItems reflection handle once.
/// </summary>
private static void EnsureMenuReflection()
{
if (_menuReflectionResolved) return;
_menuReflectionResolved = true;
try
{
System.Type menuType = typeof(EditorWindow).Assembly.GetType("UnityEditor.Menu");
if (menuType == null) return;
_getMenuItemsMethod = menuType.GetMethod(
"GetMenuItems",
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
null,
new System.Type[] { typeof(string), typeof(bool), typeof(bool) },
null
);
}
catch (Exception e)
{
CodelyLogger.LogWarning($"[ExecuteMenuItem] Failed to resolve Menu.GetMenuItems: {e.Message}");
}
}
/// <summary>
/// Supplemental enumeration via [MenuItem]-attributed methods, filtered to the requested
/// root path and merged into <paramref name="outPaths"/>. Catches script-defined items
/// the reflection pass may miss; misses purely native items but works on any Unity version.
/// </summary>
private static void CollectMenuPathsViaTypeCache(string rootPath, HashSet<string> outPaths)
{
try
{
string prefix = string.IsNullOrEmpty(rootPath) ? null : rootPath;
foreach (MethodInfo method in TypeCache.GetMethodsWithAttribute<MenuItem>())
{
foreach (MenuItem attr in method.GetCustomAttributes(typeof(MenuItem), false))
{
if (attr == null || attr.validate) continue;
string menuPath = StripMenuShortcut(attr.menuItem);
if (string.IsNullOrEmpty(menuPath)) continue;
// Context-menu entries (CONTEXT/...) are not invokable top-level menus.
if (menuPath.StartsWith("CONTEXT/", StringComparison.OrdinalIgnoreCase)) continue;
if (prefix != null && !menuPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
continue;
outPaths.Add(menuPath);
}
}
}
catch (Exception e)
{
CodelyLogger.LogWarning($"[ExecuteMenuItem] TypeCache enumeration failed: {e.Message}");
}
}
// Matches a complete Unity menu hotkey token: any combination of modifier markers
// (% Ctrl/Cmd, # Shift, & Alt) and an optional '_' (no-modifier) prefix, followed by a
// single key — an alphanumeric character, an F-key (F1-F12), or a named special key.
private static readonly Regex _shortcutTailPattern = new Regex(
@"^[%#&]*_?(?:[A-Za-z0-9]|F(?:[1-9]|1[0-2])|LEFT|RIGHT|UP|DOWN|HOME|END|PGUP|PGDN|INS|DEL|TAB|SPACE|BACKSPACE|RETURN|ENTER|KP[0-9])$",
RegexOptions.Compiled);
/// <summary>
/// Strips a trailing keyboard-shortcut token from a [MenuItem] path
/// (e.g. "Tools/Do It %#g" -> "Tools/Do It") so it matches the path
/// EditorApplication.ExecuteMenuItem expects.
/// </summary>
private static string StripMenuShortcut(string menuItem)
{
if (string.IsNullOrEmpty(menuItem)) return menuItem;
int lastSpace = menuItem.LastIndexOf(' ');
if (lastSpace <= 0) return menuItem.Trim();
string tail = menuItem.Substring(lastSpace + 1);
// A shortcut token must begin with a marker (% # & or _) AND match the full hotkey
// grammar. Requiring the full match prevents stripping a legitimate trailing word
// that merely starts with one of those characters, e.g. "Tools/Do _Thing" or a name
// ending in "#5x" — only "modifiers + single key" tokens like "%#g" or "_F5" qualify.
bool startsWithMarker = tail.Length > 0 &&
(tail[0] == '%' || tail[0] == '#' || tail[0] == '&' || tail[0] == '_');
if (startsWithMarker && _shortcutTailPattern.IsMatch(tail))
{
return menuItem.Substring(0, lastSpace).TrimEnd();
}
return menuItem.Trim();
}
}
}