using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Globalization; using System.Text; using System.Text.RegularExpressions; using UnityEditor; using UnityEditor.SceneManagement; using System.Diagnostics; using UnityEngine; using UnityEngine.UI; using System.Linq; public class XWorldUtil { private const string MAIN_SCENE = "Assets/Scenes/Main.unity"; private const string UI_PREFAB_DIR = "Assets/Game/Art/UI/Prefab"; private const string DEFAULT_HTML_DIR = "Assets/Doc/Output"; private const string AUTO_CONVERT_JSON_ON_FOCUS_PREF_KEY = "XWorld.UI.AutoConvertJsonOnFocus"; private const string AUTO_CONVERT_HTML_ON_FOCUS_PREF_KEY = "XWorld.UI.AutoConvertHtmlOnFocus"; private const string LOCAL_SERVER_EXE = "../../Server/build-gateway-local3/Debug/AIProjectServer.exe"; private const string LOCAL_SERVER_DLL_DIR = "../../Server/build-local-sqlite/vcpkg_installed/x64-windows/debug/bin"; private const string LOCAL_SERVER_LOG_DIR = "../../Server/logs"; private const string MINI_GAME_GATEWAY_RUNNER_DLL = "../../Server/Gateway.Runner/bin/Debug/net10.0/XWorld.Server.Gateway.Runner.dll"; private const string MINI_GAME_SERVER_GAMES_ROOT = "../../deploy/minigame"; private const string MINI_GAME_GATEWAY_PID_FILE = "pc-minigame-gateway.pid"; private const string MINI_GAME_CDN_RUNNER_DLL = "../../Server/Cdn.Runner/bin/Debug/net10.0/XWorld.Server.Cdn.Runner.dll"; private const string MINI_GAME_CDN_ROOT = "../../CDN"; private const string MINI_GAME_CDN_PID_FILE = "pc-cdn.pid"; private const int MINI_GAME_CDN_PORT = 15081; private const string DEV_DISCOVERY_TOKEN = "xw-dev"; // 须与客户端 GlobalData.LanDiscoveryToken 一致 private const int LOCAL_GATEWAY_PORT = 7777; private const int LOCAL_INTERNAL_GATEWAY_PORT = 7000; private const int LOCAL_INTERNAL_LOGIN_PORT = 7001; private const int LOCAL_INTERNAL_WORLD_PORT = 7002; private const int LOCAL_INTERNAL_GAME_PORT = 7003; private const int MINI_GAME_GATEWAY_PORT = 5005; private const int MINI_GAME_TICK_MS = 100; private const int MINI_GAME_MATCH_TIMEOUT_TICKS = 150; private static readonly List LocalServerProcesses = new List(); private static readonly object LocalServerLogLock = new object(); static int MAX_FILE_HASH = 8; static string m_sPlatform = "pc"; //导出到../../XDevNode/data static string m_XWOutputPath = "../XDevNode/data"; static string m_CurID = "local"; static List maps = new List(); static List paths = new List(); static List files = new List(); static Dictionary m_mapResNameDic = new Dictionary(); static Dictionary m_mapDepResNameDic = new Dictionary(); static Dictionary m_dicFilelist = new Dictionary(); static string m_strResFileMD5; public static void SetOutputPath(string path) { m_XWOutputPath = path; } public static void SetCurID(string b64XID) { m_CurID = b64XID; } static string AppDataPath { get { return Application.dataPath.ToLower(); }//Assets目录 } public static string GetUpdatePath() { return $"{m_XWOutputPath}/{m_sPlatform}/{m_CurID}/"; } //[MenuItem("XWorld/导出", false, 801)] static public void Export() { //WebGL BuildHotFix("game", BuildTargetGroup.WebGL, BuildTarget.WebGL); //Android //iOS //pc } [MenuItem("XWorld/UI/HTML 转 Prefab", false, 701)] static public void ConvertHtmlToPrefabMenu() { string htmlFullPath = EditorUtility.OpenFilePanel("选择 UI HTML 设计稿", Path.GetFullPath(DEFAULT_HTML_DIR), "html"); if (string.IsNullOrEmpty(htmlFullPath)) return; try { string prefabPath = ConvertHtmlToPrefab(htmlFullPath); EditorUtility.DisplayDialog("HTML 转 Prefab", $"生成完成:\n{prefabPath}", "OK"); } catch (Exception ex) { UnityEngine.Debug.LogException(ex); EditorUtility.DisplayDialog("HTML 转 Prefab 失败", ex.Message, "OK"); } } [MenuItem("XWorld/UI/Regenerate Auth Login Prefab", false, 702)] static public void ConvertAuthRegisterLoginHtmlToPrefab() { string htmlFullPath = Path.Combine(Application.dataPath, "Doc/Output/AuthRegisterLogin.html"); string prefabPath = ConvertHtmlToPrefab(htmlFullPath); UnityEngine.Debug.Log($"Generated auth login prefab: {prefabPath}"); } [MenuItem("XWorld/UI/Regenerate Match Waiting Prefab", false, 703)] static public void ConvertMatchWaitingHtmlToPrefab() { string htmlFullPath = Path.Combine(Application.dataPath, "Doc/Output/MatchWaiting.html"); string prefabPath = ConvertHtmlToPrefab(htmlFullPath); PostProcessConvertedHtmlPrefab(htmlFullPath, prefabPath); UnityEngine.Debug.Log($"Generated match waiting prefab: {prefabPath}"); } [MenuItem("XWorld/UI/Convert Changed JSON Prefabs Now", false, 704)] static public void ConvertChangedJsonPrefabsNow() { ConvertChangedJsonPrefabs(false, "manual"); } [MenuItem("XWorld/UI/Auto Convert JSON On Focus", false, 705)] static public void ToggleAutoConvertJsonOnFocus() { bool enabled = !IsAutoConvertJsonOnFocusEnabled(); EditorPrefs.SetBool(AUTO_CONVERT_JSON_ON_FOCUS_PREF_KEY, enabled); Menu.SetChecked("XWorld/UI/Auto Convert JSON On Focus", enabled); UnityEngine.Debug.Log($"Auto convert JSON on Unity focus: {(enabled ? "enabled" : "disabled")}"); } [MenuItem("XWorld/UI/Auto Convert JSON On Focus", true)] static public bool ToggleAutoConvertJsonOnFocusValidate() { Menu.SetChecked("XWorld/UI/Auto Convert JSON On Focus", IsAutoConvertJsonOnFocusEnabled()); return true; } static public void AutoConvertChangedJsonPrefabsOnEditorFocus() { if (!IsAutoConvertJsonOnFocusEnabled()) return; ConvertChangedJsonPrefabs(false, "focus"); } static bool IsAutoConvertJsonOnFocusEnabled() { if (EditorPrefs.HasKey(AUTO_CONVERT_JSON_ON_FOCUS_PREF_KEY)) return EditorPrefs.GetBool(AUTO_CONVERT_JSON_ON_FOCUS_PREF_KEY, true); return EditorPrefs.GetBool(AUTO_CONVERT_HTML_ON_FOCUS_PREF_KEY, true); } static bool s_IsAutoConvertingJsonPrefabs; static double s_LastAutoConvertJsonPrefabTime; static int ConvertChangedJsonPrefabs(bool force, string reason) { if (s_IsAutoConvertingJsonPrefabs) return 0; if (!force && EditorApplication.timeSinceStartup - s_LastAutoConvertJsonPrefabTime < 1.0) return 0; if (EditorApplication.isCompiling || EditorApplication.isUpdating || EditorApplication.isPlayingOrWillChangePlaymode) return 0; s_IsAutoConvertingJsonPrefabs = true; s_LastAutoConvertJsonPrefabTime = EditorApplication.timeSinceStartup; try { string jsonRoot = XGame.EditorTools.JsonUIPrefabBuilder.ResolveDefaultJsonRoot(); return XGame.EditorTools.JsonUIPrefabBuilder.BuildChanged(jsonRoot, reason, force).Count; } catch (Exception ex) { UnityEngine.Debug.LogException(ex); return 0; } finally { s_IsAutoConvertingJsonPrefabs = false; } } static bool s_IsAutoConvertingHtmlPrefabs; static double s_LastAutoConvertHtmlPrefabTime; static int ConvertChangedHtmlPrefabs(bool force, string reason) { if (s_IsAutoConvertingHtmlPrefabs) return 0; if (!force && EditorApplication.timeSinceStartup - s_LastAutoConvertHtmlPrefabTime < 1.0) return 0; if (EditorApplication.isCompiling || EditorApplication.isUpdating || EditorApplication.isPlayingOrWillChangePlaymode) return 0; string htmlDir = Path.Combine(Application.dataPath, DEFAULT_HTML_DIR.Substring("Assets/".Length)); if (!Directory.Exists(htmlDir)) return 0; int converted = 0; s_IsAutoConvertingHtmlPrefabs = true; s_LastAutoConvertHtmlPrefabTime = EditorApplication.timeSinceStartup; try { foreach (string htmlFullPath in Directory.GetFiles(htmlDir, "*.html", SearchOption.TopDirectoryOnly)) { string prefabPath = GetHtmlOutputPrefabAssetPath(htmlFullPath); if (string.IsNullOrEmpty(prefabPath)) continue; if (!force && !IsHtmlNewerThanPrefab(htmlFullPath, prefabPath)) continue; string generatedPrefabPath = ConvertHtmlToPrefab(htmlFullPath); PostProcessConvertedHtmlPrefab(htmlFullPath, generatedPrefabPath); converted++; UnityEngine.Debug.Log($"[HTML2Prefab:{reason}] {Path.GetFileName(htmlFullPath)} -> {generatedPrefabPath}"); } } catch (Exception ex) { UnityEngine.Debug.LogException(ex); } finally { s_IsAutoConvertingHtmlPrefabs = false; } return converted; } static string GetHtmlOutputPrefabAssetPath(string htmlFullPath) { if (!File.Exists(htmlFullPath)) return null; string html = File.ReadAllText(htmlFullPath, Encoding.UTF8); Dictionary meta = ParseUnityPrefabMeta(html); string outputPrefabPath = GetMetaValue(meta, "outputPrefabPath"); string prefabName = GetMetaValue(meta, "prefabName"); string outputFileName = string.IsNullOrEmpty(outputPrefabPath) ? $"{prefabName}.prefab" : Path.GetFileName(outputPrefabPath); if (string.IsNullOrEmpty(outputFileName) || outputFileName == ".prefab") outputFileName = $"{Path.GetFileNameWithoutExtension(htmlFullPath)}.prefab"; return NormalizeAssetPath($"{UI_PREFAB_DIR}/{outputFileName}"); } static bool IsHtmlNewerThanPrefab(string htmlFullPath, string prefabAssetPath) { string prefabFullPath = Path.Combine(Directory.GetParent(Application.dataPath).FullName, NormalizeAssetPath(prefabAssetPath)); if (!File.Exists(prefabFullPath)) return true; return File.GetLastWriteTimeUtc(htmlFullPath) > File.GetLastWriteTimeUtc(prefabFullPath).AddMilliseconds(100); } static void PostProcessConvertedHtmlPrefab(string htmlFullPath, string prefabPath) { string fileName = Path.GetFileName(htmlFullPath); if (string.Equals(fileName, "MatchWaiting.html", StringComparison.OrdinalIgnoreCase)) { EnsurePrefabRootComponent(prefabPath, "XGame.MatchWaitingController"); } } static void EnsurePrefabRootComponent(string prefabPath, string componentTypeName) { Type componentType = FindType(componentTypeName); if (componentType == null || !typeof(Component).IsAssignableFrom(componentType)) { UnityEngine.Debug.LogWarning($"Component type not found: {componentTypeName}"); return; } GameObject root = PrefabUtility.LoadPrefabContents(prefabPath); try { if (root.GetComponent(componentType) == null) { root.AddComponent(componentType); PrefabUtility.SaveAsPrefabAsset(root, prefabPath); } } finally { PrefabUtility.UnloadPrefabContents(root); } } static public string ConvertHtmlToPrefab(string htmlFullPath) { if (!File.Exists(htmlFullPath)) throw new FileNotFoundException("HTML 文件不存在", htmlFullPath); string html = File.ReadAllText(htmlFullPath, Encoding.UTF8); HtmlDocumentData document = ParseHtmlDocument(html); HtmlNode rootNode = FindUnityRoot(document.Root); if (rootNode == null) throw new Exception("HTML 中没有找到 data-unity-root=\"true\" 的根节点。"); Dictionary meta = ParseUnityPrefabMeta(html); string prefabName = GetMetaValue(meta, "prefabName"); if (string.IsNullOrEmpty(prefabName)) prefabName = GetAttr(rootNode, "data-unity-name", Path.GetFileNameWithoutExtension(htmlFullPath)); string outputPrefabPath = GetMetaValue(meta, "outputPrefabPath"); string outputFileName = string.IsNullOrEmpty(outputPrefabPath) ? $"{prefabName}.prefab" : Path.GetFileName(outputPrefabPath); if (string.IsNullOrEmpty(outputFileName)) outputFileName = $"{prefabName}.prefab"; outputPrefabPath = NormalizeAssetPath($"{UI_PREFAB_DIR}/{outputFileName}"); EnsureAssetDirectory(Path.GetDirectoryName(outputPrefabPath)); GameObject prefabRoot = BuildUnityNode(rootNode, null, document.CssRules, true); prefabRoot.name = prefabName; PrefabUtility.SaveAsPrefabAsset(prefabRoot, outputPrefabPath); UnityEngine.Object.DestroyImmediate(prefabRoot); AssetDatabase.Refresh(); return outputPrefabPath; } [MenuItem("XWorld/Server/Open Local Server (All)", false, 601)] static public void OpenLocalServerAll() { StopExitedLocalServerProcesses(); SetLocalServerPrefs(); // 内网 CDN(HTTP 静态文件)独立于网关,先确保起来(端口已开则跳过) StartMiniGameCdnRunnerProcess("XWorld PC CDN"); if (IsVisibleMiniGameGatewayConsoleRunning()) { UnityEngine.Debug.Log("PC MiniGame Gateway console is already running."); return; } if (IsMiniGameGatewayRunnerRunning()) { UnityEngine.Debug.Log("PC MiniGame Gateway is running without a tracked console; restarting it with a visible console."); StopMiniGameGatewayRunnerFromPidFile(); WaitForMiniGameGatewayPortToClose(3000); if (IsTcpPortOpen("127.0.0.1", MINI_GAME_GATEWAY_PORT)) { EditorUtility.DisplayDialog("XWorld Server", "PC MiniGame Gateway port " + MINI_GAME_GATEWAY_PORT + " is already in use, but the process is not tracked by Unity.\n\n" + "Please stop the existing Gateway.Runner/dotnet process, then press Play again.", "OK"); return; } } StartMiniGameGatewayRunnerProcess("XWorld PC minigame gateway"); } [MenuItem("XWorld/Server/Open Local Server (Separate)", false, 602)] static public void OpenLocalServerSeparate() { UnityEngine.Debug.Log("Separate legacy server startup is disabled for the PC minigame flow; starting Gateway.Runner instead."); OpenLocalServerAll(); } [MenuItem("XWorld/Server/Stop Local Server", false, 603)] static public void StopLocalServer() { StopExitedLocalServerProcesses(); foreach (Process process in LocalServerProcesses.ToArray()) { TryStopProcess(process); } LocalServerProcesses.Clear(); foreach (Process process in Process.GetProcessesByName("AIProjectServer")) { TryStopProcess(process); } StopMiniGameGatewayRunnerFromPidFile(); EditorUtility.DisplayDialog("XWorld Server", "Local server stopped.", "OK"); } [MenuItem("XWorld/Server/Status", false, 604)] static public void LocalServerStatus() { StopExitedLocalServerProcesses(); Process[] processes = Process.GetProcessesByName("AIProjectServer"); int pid = ReadMiniGameGatewayPid(); EditorUtility.DisplayDialog( "XWorld Server", "Tracked processes: " + LocalServerProcesses.Count + "\nRunning AIProjectServer processes: " + processes.Length + "\nPC MiniGame Gateway pid: " + (pid > 0 && IsProcessAlive(pid) ? pid.ToString() : "not running") + "\nPC MiniGame Gateway port " + MINI_GAME_GATEWAY_PORT + ": " + (IsTcpPortOpen("127.0.0.1", MINI_GAME_GATEWAY_PORT) ? "open" : "closed"), "OK"); } static public bool IsPcMiniGameGatewayReady() { return IsMiniGameGatewayRunnerRunning(); } [MenuItem("XWorld/Server/Open Latest Log", false, 605)] static public void OpenLatestLocalServerLog() { string logDir = Path.GetFullPath(Path.Combine(Application.dataPath, LOCAL_SERVER_LOG_DIR)); if (!Directory.Exists(logDir)) { EditorUtility.DisplayDialog("XWorld Server", "Log directory not found:\n" + logDir, "OK"); return; } FileInfo latest = new DirectoryInfo(logDir) .GetFiles("*.log") .OrderByDescending(file => file.LastWriteTimeUtc) .FirstOrDefault(); if (latest == null) { EditorUtility.DisplayDialog("XWorld Server", "No server log found:\n" + logDir, "OK"); return; } EditorUtility.OpenWithDefaultApp(latest.FullName); } [MenuItem("XWorld/Server/Open Local Server (All)", true)] [MenuItem("XWorld/Server/Open Local Server (Separate)", true)] static public bool CanOpenLocalServer() { return File.Exists(GetMiniGameGatewayRunnerPath()); } [MenuItem("XWorld/Server/Stop Local Server", true)] static public bool CanStopLocalServer() { int pid = ReadMiniGameGatewayPid(); return LocalServerProcesses.Count > 0 || IsProcessRunning("AIProjectServer") || (pid > 0 && IsProcessAlive(pid)); } [MenuItem("XWorld/Start Game", false, 801)] static public void Run() { //传输文件, //启动运行节点 if (IsProcessRunning("XWorldNode")) { RunExecutable("../XWorld/XWorldNode.exe"); } //运行游戏 if (EditorApplication.isPlaying) { UnityEditor.EditorApplication.playModeStateChanged += OnPlayModeStateChanged; EditorApplication.ExitPlaymode(); } else { EditorSceneManager.OpenScene(MAIN_SCENE); EditorApplication.EnterPlaymode(); } } static private void OnPlayModeStateChanged(PlayModeStateChange state) { if (state == PlayModeStateChange.EnteredEditMode) { UnityEditor.EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; EditorSceneManager.OpenScene(MAIN_SCENE); EditorApplication.EnterPlaymode(); } } static private string GetProjectRoot() { return Path.GetFullPath(Path.Combine(Application.dataPath, "../..")); } static private string GetLocalServerExePath() { return Path.GetFullPath(Path.Combine(Application.dataPath, LOCAL_SERVER_EXE)); } static private string GetLocalServerDllPath() { return Path.GetFullPath(Path.Combine(Application.dataPath, LOCAL_SERVER_DLL_DIR)); } static private string GetMiniGameGatewayRunnerPath() { return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_GATEWAY_RUNNER_DLL)); } static private string GetMiniGameServerGamesRoot() { return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_SERVER_GAMES_ROOT)); } static private string GetMiniGameGatewayPidPath() { string logDir = Path.GetFullPath(Path.Combine(Application.dataPath, LOCAL_SERVER_LOG_DIR)); Directory.CreateDirectory(logDir); return Path.Combine(logDir, MINI_GAME_GATEWAY_PID_FILE); } static private string GetMiniGameCdnRunnerPath() { return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_RUNNER_DLL)); } static private string GetMiniGameCdnRoot() { return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_ROOT)); } static private string GetMiniGameCdnPidPath() { string logDir = Path.GetFullPath(Path.Combine(Application.dataPath, LOCAL_SERVER_LOG_DIR)); Directory.CreateDirectory(logDir); return Path.Combine(logDir, MINI_GAME_CDN_PID_FILE); } static private string GetLocalServerLogPath(string title) { string logDir = Path.GetFullPath(Path.Combine(Application.dataPath, LOCAL_SERVER_LOG_DIR)); Directory.CreateDirectory(logDir); string safeTitle = Regex.Replace(title, "[^a-zA-Z0-9_-]+", "-").Trim('-'); if (string.IsNullOrEmpty(safeTitle)) safeTitle = "server"; return Path.Combine(logDir, safeTitle + "-" + DateTime.Now.ToString("yyyyMMdd-HHmmss") + ".log"); } static private void StartServerProcess(string arguments, string title) { string exePath = GetLocalServerExePath(); if (!File.Exists(exePath)) { EditorUtility.DisplayDialog("XWorld Server", "Server executable not found:\n" + exePath + "\n\nBuild Server first.", "OK"); return; } string dllPath = GetLocalServerDllPath(); if (!Directory.Exists(dllPath)) { EditorUtility.DisplayDialog("XWorld Server", "Server DLL directory not found:\n" + dllPath + "\n\nBuild Server dependencies first.", "OK"); return; } string logPath = GetLocalServerLogPath(title); string scriptPath = Path.Combine(Path.GetDirectoryName(logPath), "run-" + Path.GetFileNameWithoutExtension(logPath) + ".ps1"); string script = "$ErrorActionPreference = 'Continue'\r\n" + "$env:PATH = '" + EscapePowerShellSingleQuoted(dllPath) + ";' + $env:PATH\r\n" + "Set-Location -LiteralPath '" + EscapePowerShellSingleQuoted(GetProjectRoot()) + "'\r\n" + "Write-Host 'Starting " + EscapePowerShellSingleQuoted(title) + "'\r\n" + "Write-Host 'Exe: " + EscapePowerShellSingleQuoted(exePath) + "'\r\n" + "Write-Host 'Args: " + EscapePowerShellSingleQuoted(arguments) + "'\r\n" + "Write-Host 'Log: " + EscapePowerShellSingleQuoted(logPath) + "'\r\n" + "& '" + EscapePowerShellSingleQuoted(exePath) + "' " + arguments + " *>&1 | Tee-Object -FilePath '" + EscapePowerShellSingleQuoted(logPath) + "'\r\n" + "Write-Host ''\r\n" + "Write-Host 'Server process exited. Press Enter to close this window.'\r\n" + "Read-Host\r\n"; File.WriteAllText(scriptPath, script, new UTF8Encoding(false)); ProcessStartInfo startInfo = new ProcessStartInfo(); startInfo.FileName = "powershell.exe"; startInfo.Arguments = "-ExecutionPolicy Bypass -File \"" + scriptPath + "\""; startInfo.WorkingDirectory = GetProjectRoot(); startInfo.UseShellExecute = true; startInfo.CreateNoWindow = false; Process process = Process.Start(startInfo); if (process != null) { LocalServerProcesses.Add(process); UnityEngine.Debug.Log(title + " started. pid=" + process.Id + " args=" + arguments + " log=" + logPath); } } static private void StartMiniGameGatewayRunnerProcess(string title) { string runnerPath = GetMiniGameGatewayRunnerPath(); if (!File.Exists(runnerPath)) { EditorUtility.DisplayDialog("XWorld Server", "Gateway runner not found:\n" + runnerPath + "\n\nBuild Server/Gateway.Runner first.", "OK"); return; } // 不做小游戏包存在性检测:网关支持运行中热更(BuildGameList 每请求重扫、建房按需加载), // 空目录照常启动,之后用菜单 [XWorld/生成小游戏更新] 发布即生效。 string gamesRoot = GetMiniGameServerGamesRoot(); Directory.CreateDirectory(gamesRoot); if (Directory.GetFiles(gamesRoot, "game.json", SearchOption.AllDirectories).Length == 0) { UnityEngine.Debug.Log("PC MiniGame Gateway: games root is empty (" + gamesRoot + "). Publish anytime via menu [XWorld/生成小游戏更新]; it takes effect while the gateway is running."); } if (IsTcpPortOpen("127.0.0.1", MINI_GAME_GATEWAY_PORT)) { UnityEngine.Debug.Log("PC MiniGame Gateway port is already open: " + MINI_GAME_GATEWAY_PORT); return; } string logPath = GetLocalServerLogPath(title); string scriptPath = Path.Combine(Path.GetDirectoryName(logPath), "run-" + Path.GetFileNameWithoutExtension(logPath) + ".ps1"); string script = "$ErrorActionPreference = 'Continue'\r\n" + "Set-Location -LiteralPath '" + EscapePowerShellSingleQuoted(GetProjectRoot()) + "'\r\n" + "$runner = '" + EscapePowerShellSingleQuoted(runnerPath) + "'\r\n" + "$gamesRoot = '" + EscapePowerShellSingleQuoted(gamesRoot) + "'\r\n" + "$logPath = '" + EscapePowerShellSingleQuoted(logPath) + "'\r\n" + "Write-Host 'Starting " + EscapePowerShellSingleQuoted(title) + "'\r\n" + "Write-Host ('Runner: ' + $runner)\r\n" + "Write-Host ('Games: ' + $gamesRoot)\r\n" + "Write-Host 'WS: ws://127.0.0.1:" + MINI_GAME_GATEWAY_PORT + "/ws?pid=1'\r\n" + "Write-Host ('Log: ' + $logPath)\r\n" + "Write-Host ''\r\n" + "& dotnet $runner --gamesRoot $gamesRoot --port " + MINI_GAME_GATEWAY_PORT + " --tickMs " + MINI_GAME_TICK_MS + " --matchTimeoutTicks " + MINI_GAME_MATCH_TIMEOUT_TICKS + " --lan --devToken " + DEV_DISCOVERY_TOKEN + // 开 DevDiscovery(UDP48923) 响应 + 广播 LAN IP;CDN 地址由 --lan 兜底为 http://:15081/ " *>&1 | Tee-Object -FilePath $logPath\r\n" + "Write-Host ''\r\n" + "Write-Host 'Gateway process exited. Press Enter to close this window.'\r\n" + "Read-Host\r\n"; File.WriteAllText(scriptPath, script, new UTF8Encoding(false)); ProcessStartInfo startInfo = new ProcessStartInfo(); startInfo.FileName = "powershell.exe"; startInfo.Arguments = "-ExecutionPolicy Bypass -File \"" + scriptPath + "\""; startInfo.WorkingDirectory = GetProjectRoot(); startInfo.UseShellExecute = true; startInfo.CreateNoWindow = false; Process process = Process.Start(startInfo); if (process == null) { EditorUtility.DisplayDialog("XWorld Server", "Failed to start Gateway.Runner.", "OK"); return; } LocalServerProcesses.Add(process); File.WriteAllText(GetMiniGameGatewayPidPath(), process.Id.ToString(), Encoding.UTF8); AppendLocalServerLog(logPath, "Started " + title + " pid=" + process.Id); AppendLocalServerLog(logPath, "powershell " + startInfo.Arguments); UnityEngine.Debug.Log(title + " started. pid=" + process.Id + " ws=ws://127.0.0.1:" + MINI_GAME_GATEWAY_PORT + "/ws?pid=1 log=" + logPath); } // 内网 CDN(HTTP 静态文件):服务仓库 CDN/ 目录,含 XWorld//(旧全量热更)与 minigame///。 static private void StartMiniGameCdnRunnerProcess(string title) { string runnerPath = GetMiniGameCdnRunnerPath(); if (!File.Exists(runnerPath)) { UnityEngine.Debug.LogWarning("[CDN] 未找到 Cdn.Runner:" + runnerPath + "\n请先构建 Server/Cdn.Runner(dotnet build Server/Server.sln)。"); return; } // 防重复:pid 文件在 Process.Start 时即写(先于 dotnet 绑端口),挡住多次 OpenLocalServerAll 之间的冷启动竞速 int existingPid = ReadMiniGameCdnPid(); if (existingPid > 0 && IsProcessAlive(existingPid)) { UnityEngine.Debug.Log("PC CDN console already running (pid " + existingPid + ")."); return; } if (IsTcpPortOpen("127.0.0.1", MINI_GAME_CDN_PORT)) { UnityEngine.Debug.Log("PC CDN port is already open: " + MINI_GAME_CDN_PORT); return; } string cdnRoot = GetMiniGameCdnRoot(); Directory.CreateDirectory(cdnRoot); string logPath = GetLocalServerLogPath(title); string scriptPath = Path.Combine(Path.GetDirectoryName(logPath), "run-" + Path.GetFileNameWithoutExtension(logPath) + ".ps1"); string script = "$ErrorActionPreference = 'Continue'\r\n" + "Set-Location -LiteralPath '" + EscapePowerShellSingleQuoted(GetProjectRoot()) + "'\r\n" + "$runner = '" + EscapePowerShellSingleQuoted(runnerPath) + "'\r\n" + "$cdnRoot = '" + EscapePowerShellSingleQuoted(cdnRoot) + "'\r\n" + "$logPath = '" + EscapePowerShellSingleQuoted(logPath) + "'\r\n" + "Write-Host 'Starting " + EscapePowerShellSingleQuoted(title) + "'\r\n" + "Write-Host ('Runner: ' + $runner)\r\n" + "Write-Host ('Root: ' + $cdnRoot)\r\n" + "Write-Host 'HTTP: http://127.0.0.1:" + MINI_GAME_CDN_PORT + "/ (XWorld// , minigame///)'\r\n" + "Write-Host ('Log: ' + $logPath)\r\n" + "Write-Host ''\r\n" + "& dotnet $runner --root $cdnRoot --port " + MINI_GAME_CDN_PORT + " --lan" + " *>&1 | Tee-Object -FilePath $logPath\r\n" + "Write-Host ''\r\n" + "Write-Host 'CDN process exited. Press Enter to close this window.'\r\n" + "Read-Host\r\n"; File.WriteAllText(scriptPath, script, new UTF8Encoding(false)); ProcessStartInfo startInfo = new ProcessStartInfo(); startInfo.FileName = "powershell.exe"; startInfo.Arguments = "-ExecutionPolicy Bypass -File \"" + scriptPath + "\""; startInfo.WorkingDirectory = GetProjectRoot(); startInfo.UseShellExecute = true; startInfo.CreateNoWindow = false; Process process = Process.Start(startInfo); if (process == null) { UnityEngine.Debug.LogWarning("[CDN] 启动 Cdn.Runner 失败。"); return; } LocalServerProcesses.Add(process); File.WriteAllText(GetMiniGameCdnPidPath(), process.Id.ToString(), Encoding.UTF8); AppendLocalServerLog(logPath, "Started " + title + " pid=" + process.Id); UnityEngine.Debug.Log(title + " started. pid=" + process.Id + " http=http://127.0.0.1:" + MINI_GAME_CDN_PORT + "/ log=" + logPath); } static private void SetLocalServerPrefs() { PlayerPrefs.SetInt("XWorldLocal", 1); PlayerPrefs.SetString("CurNode", "ws://127.0.0.1:" + MINI_GAME_GATEWAY_PORT + "/ws?pid=1"); PlayerPrefs.SetString("CurCDN", "http://127.0.0.1:" + MINI_GAME_CDN_PORT + "/"); PlayerPrefs.Save(); } static private string EscapePowerShellSingleQuoted(string text) { return (text ?? string.Empty).Replace("'", "''"); } static private void StopExitedLocalServerProcesses() { LocalServerProcesses.RemoveAll(process => { try { return process == null || process.HasExited; } catch { return true; } }); } static private void TryStopProcess(Process process) { if (process == null) return; try { if (!process.HasExited) { process.Kill(); process.WaitForExit(2000); } } catch (Exception ex) { UnityEngine.Debug.LogWarning("Stop process failed: " + ex.Message); } } static private void StopMiniGameGatewayRunnerFromPidFile() { int pid = ReadMiniGameGatewayPid(); if (pid > 0) { try { TryStopProcess(Process.GetProcessById(pid)); } catch (Exception ex) { UnityEngine.Debug.LogWarning("Stop Gateway.Runner by pid failed: " + ex.Message); } } string pidPath = GetMiniGameGatewayPidPath(); if (File.Exists(pidPath)) File.Delete(pidPath); } static private int ReadMiniGameGatewayPid() { string pidPath = GetMiniGameGatewayPidPath(); if (!File.Exists(pidPath)) return -1; int pid; return int.TryParse(File.ReadAllText(pidPath).Trim(), out pid) ? pid : -1; } static private int ReadMiniGameCdnPid() { string pidPath = GetMiniGameCdnPidPath(); if (!File.Exists(pidPath)) return -1; int pid; return int.TryParse(File.ReadAllText(pidPath).Trim(), out pid) ? pid : -1; } static private bool IsMiniGameGatewayRunnerRunning() { StopExitedLocalServerProcesses(); foreach (Process process in LocalServerProcesses) { try { if (process != null && !process.HasExited) return true; } catch { } } int pid = ReadMiniGameGatewayPid(); return pid > 0 && IsProcessAlive(pid) || IsTcpPortOpen("127.0.0.1", MINI_GAME_GATEWAY_PORT); } static private bool IsVisibleMiniGameGatewayConsoleRunning() { int pid = ReadMiniGameGatewayPid(); if (pid <= 0) return false; try { Process process = Process.GetProcessById(pid); if (process == null || process.HasExited) return false; string name = process.ProcessName ?? string.Empty; return name.IndexOf("powershell", StringComparison.OrdinalIgnoreCase) >= 0 || name.IndexOf("pwsh", StringComparison.OrdinalIgnoreCase) >= 0 || name.IndexOf("WindowsTerminal", StringComparison.OrdinalIgnoreCase) >= 0; } catch { return false; } } static private void WaitForMiniGameGatewayPortToClose(int timeoutMs) { DateTime deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (DateTime.UtcNow < deadline) { if (!IsTcpPortOpen("127.0.0.1", MINI_GAME_GATEWAY_PORT)) return; System.Threading.Thread.Sleep(100); } } static private bool IsProcessAlive(int pid) { try { Process process = Process.GetProcessById(pid); return process != null && !process.HasExited; } catch { return false; } } static private bool IsTcpPortOpen(string host, int port) { try { using (var client = new System.Net.Sockets.TcpClient()) { IAsyncResult result = client.BeginConnect(host, port, null, null); bool connected = result.AsyncWaitHandle.WaitOne(100); if (!connected) return false; client.EndConnect(result); return true; } } catch { return false; } } static private void AppendLocalServerLog(string logPath, string line) { if (string.IsNullOrEmpty(line)) return; lock (LocalServerLogLock) { File.AppendAllText(logPath, DateTime.Now.ToString("HH:mm:ss.fff ") + line + Environment.NewLine, Encoding.UTF8); } } static private string QuoteProcessArg(string value) { return "\"" + (value ?? string.Empty).Replace("\"", "\\\"") + "\""; } static private int SafeExitCode(Process process) { try { return process.ExitCode; } catch { return -1; } } static private void RunExecutable(string path) { if (System.IO.File.Exists(path)) { Process.Start(path); EditorUtility.DisplayDialog("Process Runner", "Executable started successfully.", "OK"); } else { EditorUtility.DisplayDialog("Process Runner", "Executable not found.", "OK"); } } static private bool IsProcessRunning(string processName) { Process[] processes = Process.GetProcessesByName(processName); return processes.Any(); } static GameObject BuildUnityNode(HtmlNode node, Transform parent, Dictionary> cssRules, bool isRoot = false, HtmlLayout layoutOverride = null) { string unityType = GetAttr(node, "data-unity-type", isRoot ? "Canvas" : "Empty"); string unityName = GetAttr(node, "data-unity-name", string.IsNullOrEmpty(node.TagName) ? "UIElement" : node.TagName); Dictionary style = GetComputedStyle(node, cssRules); GameObject go = new GameObject(unityName, typeof(RectTransform)); if (parent != null) go.transform.SetParent(parent, false); RectTransform rect = go.GetComponent(); ApplyRectTransform(rect, node, style, isRoot, layoutOverride); Graphic graphic = null; Selectable selectable = null; switch (unityType) { case "Canvas": Canvas canvas = go.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; CanvasScaler scaler = go.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = GetDesignResolution(node); scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight; scaler.matchWidthOrHeight = 0.5f; go.AddComponent(); if (style.ContainsKey("background-color") || HasAttr(node, "data-color")) AddImage(go, node, style); break; case "Panel": case "Image": graphic = AddImage(go, node, style); break; case "Button": graphic = AddImage(go, node, style); Button button = go.AddComponent