Files
2026-07-10 10:24:29 +08:00

2506 lines
96 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Process> LocalServerProcesses = new List<Process>();
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<AssetBundleBuild> maps = new List<AssetBundleBuild>();
static List<string> paths = new List<string>();
static List<string> files = new List<string>();
static Dictionary<string, string> m_mapResNameDic = new Dictionary<string, string>();
static Dictionary<string, string> m_mapDepResNameDic = new Dictionary<string, string>();
static Dictionary<string, string> m_dicFilelist = new Dictionary<string, string>();
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<string, string> 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<string, string> 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 IPCDN 地址由 --lan 兜底为 http://<lanip>: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/<platform>/(旧全量热更)与 minigame/<id>/<ver>/。
static private void StartMiniGameCdnRunnerProcess(string title)
{
string runnerPath = GetMiniGameCdnRunnerPath();
if (!File.Exists(runnerPath))
{
UnityEngine.Debug.LogWarning("[CDN] 未找到 Cdn.Runner" + runnerPath + "\n请先构建 Server/Cdn.Runnerdotnet 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/<platform>/ , minigame/<id>/<ver>/)'\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<string, Dictionary<string, string>> 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<string, string> style = GetComputedStyle(node, cssRules);
GameObject go = new GameObject(unityName, typeof(RectTransform));
if (parent != null)
go.transform.SetParent(parent, false);
RectTransform rect = go.GetComponent<RectTransform>();
ApplyRectTransform(rect, node, style, isRoot, layoutOverride);
Graphic graphic = null;
Selectable selectable = null;
switch (unityType)
{
case "Canvas":
Canvas canvas = go.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
CanvasScaler scaler = go.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = GetDesignResolution(node);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
go.AddComponent<GraphicRaycaster>();
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<Button>();
ApplyButtonSprites(button, node);
selectable = button;
Graphic labelGraphic = EnsureButtonLabel(go.transform, node, style, graphic == null);
button.targetGraphic = graphic != null ? graphic : labelGraphic;
break;
case "TextMeshProUGUI":
AddText(go, node, style);
break;
case "TMP_InputField":
graphic = AddImage(go, node, style);
Component input = AddComponentByTypeName(go, "TMPro.TMP_InputField");
if (input == null)
throw new Exception("项目未能创建 TMPro.TMP_InputField,请确认 TextMeshPro 包已安装并被当前程序集引用。");
SetObjectProperty(input, "targetGraphic", graphic);
SetObjectProperty(input, "characterLimit", ParseInt(GetAttr(node, "data-character-limit", "0"), 0));
SetObjectProperty(input, "contentType", ParseEnumProperty(input, "contentType", GetAttr(node, "data-content-type", "Standard")));
CreateInputTextChildren(go.transform, node, input);
break;
case "Toggle":
graphic = AddImage(go, node, style);
Toggle toggle = go.AddComponent<Toggle>();
toggle.targetGraphic = graphic;
toggle.isOn = ParseBool(GetAttr(node, "data-is-on", "false"));
CreateToggleGraphicChildren(go.transform, node, toggle);
selectable = toggle;
break;
case "Slider":
go.AddComponent<Slider>();
break;
case "Scrollbar":
go.AddComponent<Scrollbar>();
break;
case "ScrollView":
go.AddComponent<ScrollRect>();
break;
}
ApplyCommonMetadata(go, node);
if (selectable != null)
selectable.navigation = new Navigation { mode = Navigation.Mode.Automatic };
Dictionary<HtmlNode, HtmlLayout> childLayouts = ComputeChildLayouts(node, rect, style, cssRules);
foreach (HtmlNode child in node.Children)
{
if (!IsUnityNode(child))
continue;
childLayouts.TryGetValue(child, out HtmlLayout childLayout);
BuildUnityNode(child, go.transform, cssRules, false, childLayout);
}
return go;
}
static Image AddImage(GameObject go, HtmlNode node, Dictionary<string, string> style)
{
string spritePath = GetFirstAttr(node, "data-sprite", "data-sprite-normal");
bool hasColor = style.ContainsKey("background-color") || HasAttr(node, "data-color");
if (string.IsNullOrEmpty(spritePath) && !hasColor)
return null;
Sprite sprite = null;
if (!string.IsNullOrEmpty(spritePath))
{
sprite = AssetDatabase.LoadAssetAtPath<Sprite>(NormalizeAssetPath(spritePath));
if (sprite == null)
{
UnityEngine.Debug.LogWarning($"HTML 转 Prefab:找不到贴图 {spritePath},节点 {GetAttr(node, "data-unity-name", go.name)} 不使用贴图。");
if (!hasColor)
return null;
}
}
Image image = go.AddComponent<Image>();
image.raycastTarget = GetAttr(node, "data-unity-type", "") == "Button" || GetAttr(node, "data-unity-type", "") == "Toggle";
image.sprite = sprite;
image.type = ParseImageType(GetAttr(node, "data-image-type", ""));
if (style.TryGetValue("background-color", out string bgColor))
image.color = ParseColor(bgColor, Color.white);
else if (HasAttr(node, "data-color"))
image.color = ParseColor(GetAttr(node, "data-color", "#FFFFFFFF"), Color.white);
return image;
}
static Component AddText(GameObject go, HtmlNode node, Dictionary<string, string> style)
{
Component text = AddComponentByTypeName(go, "TMPro.TextMeshProUGUI");
if (text == null)
throw new Exception("项目未能创建 TMPro.TextMeshProUGUI,请确认 TextMeshPro 包已安装并被当前程序集引用。");
SetObjectProperty(text, "raycastTarget", false);
SetObjectProperty(text, "text", HtmlDecode(GetAttr(node, "data-text", GetInnerText(node))));
SetObjectProperty(text, "fontSize", ParseFloat(GetFirstValue(node, style, "data-font-size", "font-size"), 24f));
SetObjectProperty(text, "color", ParseColor(GetFirstValue(node, style, "data-color", "color"), Color.white));
SetObjectProperty(text, "alignment", ParseTextAlignment(text, GetAttr(node, "data-alignment", GetCssTextAlign(style))));
SetObjectProperty(text, "fontStyle", ParseFontStyle(text, node, style));
bool hasExplicitWidth = HasStyle(style, "width");
SetObjectProperty(text, "enableWordWrapping", hasExplicitWidth);
if (!hasExplicitWidth)
SetObjectProperty(text, "overflowMode", ParseTmpEnum(text, "overflowMode", "Overflow"));
return text;
}
static void ApplyButtonSprites(Button button, HtmlNode node)
{
if (GetAttr(node, "data-button-transition", "") != "SpriteSwap")
return;
button.transition = Selectable.Transition.SpriteSwap;
SpriteState state = button.spriteState;
state.highlightedSprite = LoadSprite(GetAttr(node, "data-sprite-highlighted", ""));
state.pressedSprite = LoadSprite(GetAttr(node, "data-sprite-pressed", ""));
state.disabledSprite = LoadSprite(GetAttr(node, "data-sprite-disabled", ""));
button.spriteState = state;
}
static Graphic EnsureButtonLabel(Transform parent, HtmlNode node, Dictionary<string, string> style, bool useAsTargetGraphic)
{
string textValue = HtmlDecode(GetAttr(node, "data-text", GetInnerText(node))).Trim();
if (string.IsNullOrEmpty(textValue) || HasTmpTextInChildren(parent))
return null;
GameObject label = new GameObject("Text", typeof(RectTransform));
label.transform.SetParent(parent, false);
RectTransform rect = label.GetComponent<RectTransform>();
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
Component text = AddComponentByTypeName(label, "TMPro.TextMeshProUGUI");
if (text == null)
throw new Exception("项目未能创建 TMPro.TextMeshProUGUI,请确认 TextMeshPro 包已安装并被当前程序集引用。");
SetObjectProperty(text, "text", textValue);
SetObjectProperty(text, "fontSize", ParseFloat(GetFirstValue(node, style, "data-font-size", "font-size"), 28f));
SetObjectProperty(text, "color", ParseColor(GetFirstValue(node, style, "data-color", "color"), Color.white));
SetObjectProperty(text, "alignment", ParseTextAlignment(text, GetAttr(node, "data-alignment", GetCssTextAlign(style))));
SetObjectProperty(text, "fontStyle", ParseFontStyle(text, node, style));
SetObjectProperty(text, "enableWordWrapping", false);
SetObjectProperty(text, "overflowMode", ParseTmpEnum(text, "overflowMode", "Overflow"));
SetObjectProperty(text, "raycastTarget", useAsTargetGraphic);
return text as Graphic;
}
static void CreateInputTextChildren(Transform parent, HtmlNode node, Component input)
{
RectTransform parentRect = parent.GetComponent<RectTransform>();
Vector2 size = parentRect != null ? parentRect.sizeDelta : new Vector2(300, 64);
GameObject textArea = new GameObject("TextArea", typeof(RectTransform), typeof(RectMask2D));
textArea.transform.SetParent(parent, false);
RectTransform areaRect = textArea.GetComponent<RectTransform>();
areaRect.anchorMin = Vector2.zero;
areaRect.anchorMax = Vector2.one;
areaRect.offsetMin = new Vector2(24, 8);
areaRect.offsetMax = new Vector2(-24, -8);
GameObject placeholderGo = new GameObject("Placeholder", typeof(RectTransform));
placeholderGo.transform.SetParent(textArea.transform, false);
RectTransform placeholderRect = placeholderGo.GetComponent<RectTransform>();
placeholderRect.anchorMin = Vector2.zero;
placeholderRect.anchorMax = Vector2.one;
placeholderRect.offsetMin = Vector2.zero;
placeholderRect.offsetMax = Vector2.zero;
Component placeholder = AddComponentByTypeName(placeholderGo, "TMPro.TextMeshProUGUI");
SetObjectProperty(placeholder, "text", GetAttr(node, "data-placeholder", ""));
SetObjectProperty(placeholder, "fontSize", 24f);
SetObjectProperty(placeholder, "color", new Color(0.49f, 0.64f, 0.70f, 1f));
SetObjectProperty(placeholder, "alignment", ParseTextAlignment(placeholder, "MidlineLeft"));
SetObjectProperty(placeholder, "raycastTarget", false);
GameObject textGo = new GameObject("Text", typeof(RectTransform));
textGo.transform.SetParent(textArea.transform, false);
RectTransform textRect = textGo.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = Vector2.zero;
textRect.offsetMax = Vector2.zero;
Component text = AddComponentByTypeName(textGo, "TMPro.TextMeshProUGUI");
SetObjectProperty(text, "text", "");
SetObjectProperty(text, "fontSize", 24f);
SetObjectProperty(text, "color", new Color(0.14f, 0.31f, 0.42f, 1f));
SetObjectProperty(text, "alignment", ParseTextAlignment(text, "MidlineLeft"));
SetObjectProperty(text, "raycastTarget", false);
SetObjectProperty(input, "textViewport", areaRect);
SetObjectProperty(input, "textComponent", text);
SetObjectProperty(input, "placeholder", placeholder);
parentRect.sizeDelta = size;
}
static void CreateToggleGraphicChildren(Transform parent, HtmlNode node, Toggle toggle)
{
string checkedSpritePath = GetAttr(node, "data-sprite-checked", "");
if (string.IsNullOrEmpty(checkedSpritePath))
return;
GameObject checkGo = new GameObject("Checkmark", typeof(RectTransform));
checkGo.transform.SetParent(parent, false);
RectTransform checkRect = checkGo.GetComponent<RectTransform>();
checkRect.anchorMin = Vector2.zero;
checkRect.anchorMax = Vector2.one;
checkRect.offsetMin = Vector2.zero;
checkRect.offsetMax = Vector2.zero;
Image checkImage = checkGo.AddComponent<Image>();
checkImage.sprite = LoadSprite(checkedSpritePath);
checkImage.type = Image.Type.Simple;
toggle.graphic = checkImage;
}
[MenuItem("XWorld/Export XWorld Base Resource", false, 802)]
static public void ExportBaseRes()
{
//SetOutputPath("XWorld");
SetCurID("XWorld");
//WebGL
//BuildHotFix("XWorld", BuildTargetGroup.WebGL, BuildTarget.WebGL);
//Android
//iOS
//pc
BuildHotFix("XWorld", BuildTargetGroup.Standalone, BuildTarget.StandaloneWindows64);
}
public static void BuildHotFix(string projPath, BuildTargetGroup targetGroup, BuildTarget target, string targetName = "")
{
string sPlatform = "pc";
//bool bLuaPackage = false;
//string[] EncryptFile = { "a.unity3d", "h.unity3d", "xhotfix/base.unity3d", "StreamingAssets", "bs.unity3d" };//
if (EditorUserBuildSettings.activeBuildTarget != target)
{
EditorUserBuildSettings.SwitchActiveBuildTarget(targetGroup, target);
}
if (target == BuildTarget.Android)
{
sPlatform = "android";
}
else if (target == BuildTarget.iOS)
{
sPlatform = "ios";
}
else if (target == BuildTarget.WebGL)
{
sPlatform = "webgl";
}
else
{
sPlatform = "pc";
}
m_sPlatform = sPlatform;
maps.Clear();
m_mapResNameDic.Clear();
m_mapDepResNameDic.Clear();
string outPath = $"{AppDataPath}/StreamingAssets/";
if (Directory.Exists(outPath))
ClearDir(outPath);
if (!Directory.Exists(outPath))
Directory.CreateDirectory(outPath);
//lua 打包
string luaPath = $"Assets/{projPath}/lua/";
BuildLuaAB(luaPath, "bs.unity3d");
//shader
PackAllPathFile("xw_shader.unity3d", $"Assets/XWorld/shader");//"Shadervariants"
//资源打包
string[] HotfixRes = { $"art"};//游戏资源目录, 按目录打资源 //, "res"
foreach (string respath in HotfixRes)
{
string tempPath = $"{ AppDataPath}/{projPath}/{respath}/";
string buildPath = $"Assets/{projPath}/{respath}/";//路径必须以Assets开始
if (!Directory.Exists(tempPath))
{
UnityEngine.Debug.LogError($"Path {tempPath} not exist!");
continue;
}
string[] dirs = Directory.GetDirectories(tempPath, "*", SearchOption.AllDirectories);
for (int i = 0; i < dirs.Length; i++)
{
string path = dirs[i];
if (path.Contains(".svn"))
continue;
string pathname = dirs[i].Replace(tempPath, string.Empty);
string name = pathname.Replace('\\', '_').Replace('/', '_');//Lua ab文件使用.替换目录分隔符
name = name.ToLower() + ".unity3d";
AddBuildMap($"{respath}_{name}", "*.*", buildPath + pathname);
AssetDatabase.Refresh();
}
AddBuildMap($"{respath}.unity3d", "*.*", $"Assets/{projPath}/{respath}/");
//AssetDatabase.Refresh();
}
AssetDatabase.Refresh();
//打ab包
BuildPipeline.BuildAssetBundles(outPath, maps.ToArray(), BuildAssetBundleOptions.ChunkBasedCompression/*| BuildAssetBundleOptions.DisableWriteTypeTree*/, target);
AssetDatabase.Refresh();
//生成更新列表
//BuildFileIndex(sPlatform);//StreamingAssets 里的除了\\xaot\\ 和 \\xhotfix (dll目录)外所有文件
//添加dll更新列表
m_dicFilelist.Clear();
m_strResFileMD5 = "";
//资源列表
BuildFileIndex("");
string update_files = $"{GetUpdatePath()}files.txt";
string update_ver = $"{GetUpdatePath()}updatever.txt";
//先清理之前的
if (Directory.Exists(GetUpdatePath()))
ClearDir(GetUpdatePath());
//复制到目标地址
CopyPath(outPath, GetUpdatePath());
UnityEngine.Debug.Log($"Copy Path :{outPath} => {GetUpdatePath()}");
//File.Copy($"{outPath}files.txt", $"{GetUpdatePath()}files.txt", true);
//File.Copy($"{outPath}updatever.txt", update_ver, true);
//复制热更资源
//CopyPath($"{outPath}render", $"{GetUpdatePath()}render");
//CopyPath($"{outPath}art", $"{GetUpdatePath()}art");
//加密
string[] EncryptFile = { "bs.unity3d" };//
foreach (string f in EncryptFile)
{
if (!File.Exists($"{AppDataPath}/StreamingAssets/{f}"))
continue;
//if (!f.Contains("bs.unity3d"))
//if (target != BuildTarget.WebGL)
{
byte[] srcdata = File.ReadAllBytes($"{AppDataPath}/StreamingAssets/{f}");
byte[] encryptdata = AESManager.Encrypt(srcdata, true);
//byte[] retdata = AESManager.Decrypt(encryptdata);
File.WriteAllBytes($"{AppDataPath}/StreamingAssets/{f}", encryptdata);
//File.WriteAllBytes($"{AppDataPath}/StreamingAssets/{f}_Encrypt", encryptdata);
}
string file = $"{GetUpdatePath()}{f}";
if (File.Exists(file))
{
File.Delete(file);
}
//File.Move($"{AppDataPath}/StreamingAssets/{f}_Encrypt", file);
File.Copy($"{AppDataPath}/StreamingAssets/{f}", file, true);
}
UnityEngine.Debug.Log($"Dest Path : {GetUpdatePath()}");
//if (target == BuildTarget.WebGL)
{//给文件加上md5后缀,以便方便webgl小程序使用UnityWebRequest自动缓存
//先删除旧的
string finally_update_ver = $"{GetUpdatePath()}updatever.txt";
string OldVerString;
string finally_update_files;
if (File.Exists(finally_update_ver))
{
OldVerString = File.ReadAllText(finally_update_ver);//有两个版本号
string[] vers = OldVerString.Split(',');
vers[0] = $"{GetUpdatePath()}dllfiles.txt_{vers[0]}";
vers[1] = $"{GetUpdatePath()}files.txt_{vers[1]}";
//foreach (var ver in vers)
{
//删除旧的更新文件
if (File.Exists(vers[0]))
{
string dllString = File.ReadAllText(vers[0]);
foreach (string f in EncryptFile)
{
int pos = dllString.IndexOf(f);
if (pos != -1)
{
string md5 = dllString.Substring(pos + f.Length + 1, 8);
if (md5 != null && md5.Length == 8)
{
string ab = $"{GetUpdatePath()}{f}_{GetFixedMd5(md5)}";
if (File.Exists(ab))
File.Delete(ab);
}
}
}
//删除旧的dllfiles.txt文件
File.Delete(vers[0]);
}
if (File.Exists(vers[1]))
{
IEnumerable<string> lines = File.ReadLines(vers[1]);
foreach (var line in lines)
{
string[] items = line.Split(",");
if (items.Length > 2)
{
string path = items[0].Substring(1);//路径去掉左边的‘{’符号
if (items[1] != null && items[1].Length == 8)//第二个是md5码
{
string ab = $"{GetUpdatePath()}{path}_{GetFixedMd5(items[1])}";
if (File.Exists(ab))
File.Delete(ab);
}
}
}
//删除旧的dllfiles.txt文件
File.Delete(vers[1]);
}
}
}
foreach (var file in m_dicFilelist)
{
string destFile = $"{GetUpdatePath()}{file.Key}_{GetFixedMd5(file.Value)}";
string srcFile = $"{GetUpdatePath()}{file.Key}";
if (!File.Exists(srcFile))
continue;
if (!File.Exists(destFile))
File.Move(srcFile, destFile);
}
//新的文件MD5 m_strDllFileMD5
finally_update_files = $"{GetUpdatePath()}files.txt_{GetFixedMd5(m_strResFileMD5)}";
if (!File.Exists(finally_update_files))
File.Move(update_files, finally_update_files);
if (File.Exists(finally_update_ver))
File.Delete(finally_update_ver);
File.Move(update_ver, finally_update_ver);
}
//
//。。。
UnityEngine.Debug.Log($"构建完成{DateTime.Now.ToString()}");
//清理中间打包目录
//Lua
string luapath = $"{AppDataPath}/l";
ClearDir(luapath, true);
File.Delete($"{luapath}.meta");
AssetDatabase.Refresh();
}
static void BuildLuaAB(string luaSrcPath, string bundleName)
{
string luapath = $"{Application.dataPath}/l/";
if (Directory.Exists(luapath))
{
Directory.Delete(luapath, true);
}
//Assets下的lua目录
CopyScriptsFile(luaSrcPath, luapath, "bytes");
AssetDatabase.Refresh();//新增文件需要刷新
var files = Directory.GetFiles($"Assets/l", "*.bytes", SearchOption.AllDirectories);
for (int i = 0; i < files.Length; i++)
{
files[i] = files[i].Replace('\\', '/');
}
AssetBundleBuild build = new AssetBundleBuild();
build.assetBundleName = bundleName;
build.assetNames = files;
maps.Add(build);
}
static void AddBuildMap(string bundleName, string pattern, string path, bool bDirPack = true)
{
var files = Array.FindAll(Directory.GetFiles(path, pattern), x => !x.EndsWith(".meta"));
if (files.Length == 0) return;
for (int i = 0; i < files.Length; i++)
{
if (!m_mapResNameDic.ContainsKey(files[i]))
{
m_mapResNameDic[files[i]] = "1";
}
files[i] = files[i].Replace('\\', '/');
}
if (bDirPack)
{
AssetBundleBuild build = new AssetBundleBuild();
build.assetBundleName = bundleName;
build.assetNames = files;
maps.Add(build);
UnityEngine.Debug.LogWarning($"AddBuildMap(dir) : {build.assetBundleName}({files.Length})");
}
else
{
string[] singlefile;
string abname = bundleName.Substring(0, bundleName.IndexOf(".unity3d"));
abname += "_";
for (int i = 0; i < files.Length; i++)
{
AssetBundleBuild build = new AssetBundleBuild();
singlefile = new string[] { files[i] };
build.assetBundleName = abname + Path.GetFileNameWithoutExtension(files[i]).ToLower() + ".unity3d";// bundleName;
build.assetNames = singlefile;
maps.Add(build);
UnityEngine.Debug.LogWarning($"AddBuildMap : {build.assetBundleName}({files.Length})");
}
}
}
static private List<string> GetAllDependencies(string mainAssetPath)
{
List<string> result = new List<string>();
string[] depends = AssetDatabase.GetDependencies(mainAssetPath, true);
foreach (string assetPath in depends)
{
// 注意:排除主资源对象
if (assetPath != mainAssetPath)
result.Add(assetPath);
}
return result;
}
static void PackAllPathFile(string bundleName, string path, string nopath = null)
{
paths.Clear(); files.Clear();
Recursive(path);
string[] filelist = files.ToArray();
List<string> total = new List<string>(filelist);
foreach (var file in filelist)
{
if (!m_mapResNameDic.ContainsKey(file))
{
m_mapResNameDic[file] = "1";
}
}
//*
//依赖文件 //需要检查不在自己的打包路径里
foreach (var file in filelist)
{
List<string> depends = GetAllDependencies(file);
foreach (var fd in depends)
{
if (fd.EndsWith(".hlsl"))
continue;
if (!m_mapResNameDic.ContainsKey(fd))
{
m_mapDepResNameDic[fd] = "1";
}
}
}//*/
if (nopath != null)
{
for (int i = total.Count - 1; i >= 0; i--)
{
var file = total[i];
if (file.Contains(nopath))
{
string[] pathParts = file.Replace('\\', '/').Split('/');
foreach (var p in pathParts)
{
if (string.Equals(p, nopath, StringComparison.OrdinalIgnoreCase))//if (p == nopath)
{
//删除不合理的
total.RemoveAt(i);
break;
}
}
}
}
}
AssetBundleBuild build = new AssetBundleBuild();
build.assetBundleName = bundleName;
build.assetNames = total.ToArray();// filelist;
maps.Add(build);
UnityEngine.Debug.Log($"ab:{bundleName} has {total.Count} file");
}
public static void ClearDir(string path, bool bDeleteSelf = false)
{
if (!Directory.Exists(path))
return;
System.IO.DirectoryInfo di = new DirectoryInfo(path);
foreach (FileInfo file in di.EnumerateFiles())
{
file.Delete();
}
foreach (DirectoryInfo dir in di.EnumerateDirectories())
{
dir.Delete(true);
}
if (bDeleteSelf)
Directory.Delete(path);
}
/// <summary>
/// 遍历目录及其子目录
/// </summary>
static void Recursive(string path)
{
if (path.Contains("\\xaot\\") || path.Contains("\\xhotfix\\") || !Directory.Exists(path))
{//忽略
return;
}
string[] names = Directory.GetFiles(path);
string[] dirs = Directory.GetDirectories(path);
foreach (string filename in names)
{
string ext = Path.GetExtension(filename);
if (ext.Equals(".meta")) continue;
files.Add(filename.Replace('\\', '/'));
}
foreach (string dir in dirs)
{
if (dir.IndexOf(".svn") >= 0)
{
continue;
}
paths.Add(dir.Replace('\\', '/'));
Recursive(dir);
}
}
static void CopyScriptsFile(string srcPath, string outPath, string fixname)
{
if (!Directory.Exists(outPath))
{
Directory.CreateDirectory(outPath);
}
string[] luaPaths = { srcPath };//AppDataPath + "/../../Scripts_c/" };
for (int i = 0; i < luaPaths.Length; i++)
{
paths.Clear(); files.Clear();
string luaDataPath = luaPaths[i].ToLower();
Recursive(luaDataPath);
int n = 0;
foreach (string f in files)
{
if (f.EndsWith(".meta")) continue;
string newfile = f.Replace(luaDataPath, "");
string newpath;
if (newfile.IndexOf(".lua") < 0)//转2进制文件
{
newpath = outPath + newfile + ".bytes";
}
else
{
newpath = outPath + newfile.Replace(".lua", "." + fixname);
}
newpath = newpath.ToLower();
string path = Path.GetDirectoryName(newpath);
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
if (File.Exists(newpath))
{
File.Delete(newpath);
}
File.Copy(f, newpath, true);
UpdateProgress(n++, files.Count, newpath);
}
}
EditorUtility.ClearProgressBar();
}
static void UpdateProgress(int progress, int progressMax, string desc)
{
string title = "Processing...[" + progress + " - " + progressMax + "]";
float value = (float)progress / (float)progressMax;
EditorUtility.DisplayProgressBar(title, desc, value);
}
static void BuildFileIndex(string dir = "")
{
string resPath = $"{AppDataPath}/StreamingAssets/{dir}/";
///----------------------创建文件列表-----------------------
string newFilePath = resPath + "files.txt";
if (File.Exists(newFilePath)) File.Delete(newFilePath);
paths.Clear(); files.Clear();
Recursive(resPath);//此处去掉了dll热更相关的目录
FileStream fs = new FileStream(newFilePath, FileMode.CreateNew);
StreamWriter sw = new StreamWriter(fs);
for (int i = 0; i < files.Count; i++)
{
string file = files[i];
if (file.EndsWith(".txt", StringComparison.OrdinalIgnoreCase))
continue;
string ext = Path.GetExtension(file);
if (file.EndsWith(".meta") || file.Contains(".DS_Store") || file.EndsWith(".manifest")) continue;
long size = 0;
string md5 = GetFixedMd5(md5file(file, out size));
string value = file.Replace(resPath, string.Empty);
if (!value.Contains(".unity3d") && value != "StreamingAssets" && value != "bs.unity3d")//去除根路径文件
continue;
sw.WriteLine($"{{{value},{md5},{size}}}");
m_dicFilelist[value] = md5;
}
sw.Close(); fs.Close();
//版本文件ver.txt
long versize = 0;
m_strResFileMD5 = GetFixedMd5(md5file(newFilePath, out versize));
string key = newFilePath.Replace(resPath, string.Empty);
m_dicFilelist[key] = m_strResFileMD5;
File.WriteAllText($"{resPath}/updatever.txt", m_strResFileMD5);
}
static void CopyPath(string srcPath, string outPath)
{
if (!Directory.Exists(outPath))
{
Directory.CreateDirectory(outPath);
}
string[] luaPaths = { srcPath };//AppDataPath + "/../../Scripts_c/" };
for (int i = 0; i < luaPaths.Length; i++)
{
paths.Clear(); files.Clear();
string luaDataPath = luaPaths[i].ToLower();
Recursive(luaDataPath);
int n = 0;
foreach (string f in files)
{
if (f.EndsWith(".meta") || f.EndsWith(".manifest")) continue;
string newfile = f.Replace(luaDataPath, "");
string newpath = outPath + newfile;
string path = Path.GetDirectoryName(newpath);
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
if (File.Exists(newpath))
{
File.Delete(newpath);
}
File.Copy(f, newpath, true);
UpdateProgress(n++, files.Count, newpath);
}
}
EditorUtility.ClearProgressBar();
}
static string GetFixedMd5(string md5)
{
if (md5 == null || md5.Length < MAX_FILE_HASH)
{
UnityEngine.Debug.Log($"md5 Error!");
return "Error";
}
return md5.Substring(0, MAX_FILE_HASH);//只取8位 或 n位
}
public static string md5file(string file, out long size)
{
try
{
FileStream fs = new FileStream(file, FileMode.Open);
System.Security.Cryptography.MD5 md5 = new System.Security.Cryptography.MD5CryptoServiceProvider();
byte[] retVal = md5.ComputeHash(fs);
size = fs.Length;
fs.Close();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < retVal.Length; i++)
{
sb.Append(retVal[i].ToString("x2"));
}
return sb.ToString();
}
catch (Exception ex)
{
throw new Exception("md5file() fail, error:" + ex.Message);
}
}
static HtmlDocumentData ParseHtmlDocument(string html)
{
HtmlDocumentData doc = new HtmlDocumentData();
doc.CssRules = ParseCssRules(html);
doc.Root = new HtmlNode { TagName = "document" };
Stack<HtmlNode> stack = new Stack<HtmlNode>();
stack.Push(doc.Root);
Regex tokenRegex = new Regex(@"<!--.*?-->|<!doctype.*?>|<(?<close>/)?(?<tag>[a-zA-Z0-9]+)(?<attrs>[^>]*)>|(?<text>[^<]+)", RegexOptions.IgnoreCase | RegexOptions.Singleline);
MatchCollection matches = tokenRegex.Matches(html);
foreach (Match match in matches)
{
if (match.Value.StartsWith("<!--") || match.Value.StartsWith("<!"))
continue;
if (match.Groups["text"].Success)
{
string text = match.Groups["text"].Value;
if (!string.IsNullOrWhiteSpace(text))
stack.Peek().Text += text;
continue;
}
string tag = match.Groups["tag"].Value.ToLowerInvariant();
if (tag == "style" || tag == "script")
{
if (!match.Groups["close"].Success)
stack.Push(new HtmlNode { TagName = tag, Parent = stack.Peek() });
else if (stack.Count > 1)
stack.Pop();
continue;
}
if (match.Groups["close"].Success)
{
while (stack.Count > 1 && stack.Peek().TagName != tag)
stack.Pop();
if (stack.Count > 1)
stack.Pop();
continue;
}
HtmlNode node = new HtmlNode
{
TagName = tag,
Parent = stack.Peek(),
Attributes = ParseAttributes(match.Groups["attrs"].Value)
};
stack.Peek().Children.Add(node);
if (!IsVoidTag(tag) && !match.Groups["attrs"].Value.TrimEnd().EndsWith("/"))
stack.Push(node);
}
return doc;
}
static Dictionary<string, string> ParseAttributes(string rawAttrs)
{
Dictionary<string, string> attrs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
Regex attrRegex = new Regex(@"(?<name>[a-zA-Z_:][-a-zA-Z0-9_:.]*)(\s*=\s*(?:""(?<value>[^""]*)""|'(?<value>[^']*)'|(?<value>[^\s""'>/]+)))?", RegexOptions.Singleline);
foreach (Match match in attrRegex.Matches(rawAttrs))
{
string name = match.Groups["name"].Value;
if (string.IsNullOrEmpty(name))
continue;
attrs[name] = HtmlDecode(match.Groups["value"].Value);
}
return attrs;
}
static Dictionary<string, Dictionary<string, string>> ParseCssRules(string html)
{
Dictionary<string, Dictionary<string, string>> rules = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
foreach (Match styleMatch in Regex.Matches(html, @"<style[^>]*>(?<css>.*?)</style>", RegexOptions.IgnoreCase | RegexOptions.Singleline))
{
string css = Regex.Replace(styleMatch.Groups["css"].Value, @"/\*.*?\*/", "", RegexOptions.Singleline);
foreach (Match ruleMatch in Regex.Matches(css, @"(?<selector>[^{]+)\{(?<body>[^}]*)\}", RegexOptions.Singleline))
{
string[] selectors = ruleMatch.Groups["selector"].Value.Split(',');
foreach (string selectorRaw in selectors)
{
string selector = selectorRaw.Trim();
if (!selector.StartsWith("."))
continue;
string className = selector.Substring(1).Split(new[] { ' ', ':', '.', '#' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (string.IsNullOrEmpty(className))
continue;
if (!rules.TryGetValue(className, out Dictionary<string, string> body))
{
body = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
rules[className] = body;
}
foreach (string item in ruleMatch.Groups["body"].Value.Split(';'))
{
int colon = item.IndexOf(':');
if (colon <= 0)
continue;
body[item.Substring(0, colon).Trim()] = item.Substring(colon + 1).Trim();
}
}
}
}
return rules;
}
static Dictionary<string, string> GetComputedStyle(HtmlNode node, Dictionary<string, Dictionary<string, string>> cssRules)
{
Dictionary<string, string> style = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string classAttr = GetAttr(node, "class", "");
foreach (string className in classAttr.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries))
{
if (!cssRules.TryGetValue(className, out Dictionary<string, string> classStyle))
continue;
foreach (var kv in classStyle)
style[kv.Key] = kv.Value;
}
string inlineStyle = GetAttr(node, "style", "");
foreach (string item in inlineStyle.Split(';'))
{
int colon = item.IndexOf(':');
if (colon <= 0)
continue;
style[item.Substring(0, colon).Trim()] = item.Substring(colon + 1).Trim();
}
return style;
}
static HtmlNode FindUnityRoot(HtmlNode node)
{
if (GetAttr(node, "data-unity-root", "").Equals("true", StringComparison.OrdinalIgnoreCase))
return node;
foreach (HtmlNode child in node.Children)
{
HtmlNode found = FindUnityRoot(child);
if (found != null)
return found;
}
return null;
}
static bool IsUnityNode(HtmlNode node)
{
return HasAttr(node, "data-unity-name") && HasAttr(node, "data-unity-type");
}
static Dictionary<HtmlNode, HtmlLayout> ComputeChildLayouts(HtmlNode parentNode, RectTransform parentRect, Dictionary<string, string> parentStyle, Dictionary<string, Dictionary<string, string>> cssRules)
{
Dictionary<HtmlNode, HtmlLayout> result = new Dictionary<HtmlNode, HtmlLayout>();
List<HtmlNode> children = parentNode.Children.Where(IsUnityNode).ToList();
if (children.Count == 0)
return result;
Vector2 parentSize = parentRect != null ? parentRect.sizeDelta : Vector2.zero;
bool isFlex = GetStyle(parentStyle, "display").Equals("flex", StringComparison.OrdinalIgnoreCase);
float cursorX = 0f;
float cursorY = 0f;
float gap = ParseCssPx(GetStyle(parentStyle, "gap"), 0f);
string parentUnityType = GetAttr(parentNode, "data-unity-type", "");
foreach (HtmlNode child in children)
{
Dictionary<string, string> childStyle = GetComputedStyle(child, cssRules);
float marginLeft = ParseCssPx(GetStyle(childStyle, "margin-left"), 0f);
float marginTop = ParseCssPx(GetStyle(childStyle, "margin-top"), 0f);
float marginRight = ParseCssPx(GetStyle(childStyle, "margin-right"), 0f);
float marginBottom = ParseCssPx(GetStyle(childStyle, "margin-bottom"), 0f);
float width = GetElementWidth(child, childStyle, GetDefaultChildWidth(child, parentSize));
float height = GetElementHeight(child, childStyle, GetDefaultChildHeight(child, parentSize));
bool hasExplicitLeft = HasStyle(childStyle, "left");
bool hasExplicitTop = HasStyle(childStyle, "top");
bool hasExplicitRight = HasStyle(childStyle, "right");
bool hasExplicitBottom = HasStyle(childStyle, "bottom");
string position = GetStyle(childStyle, "position").ToLowerInvariant();
bool fillParentText = children.Count == 1
&& GetAttr(child, "data-unity-type", "") == "TextMeshProUGUI"
&& (parentUnityType == "Image" || parentUnityType == "Button" || parentUnityType == "Panel")
&& !hasExplicitLeft
&& !hasExplicitTop
&& !HasStyle(childStyle, "width")
&& !HasStyle(childStyle, "height");
float left;
float top;
if (fillParentText)
{
left = 0f;
top = 0f;
width = parentSize.x;
height = parentSize.y;
}
else if (hasExplicitLeft || hasExplicitTop || hasExplicitRight || hasExplicitBottom || position == "absolute")
{
left = hasExplicitLeft
? ParseCssPx(GetStyle(childStyle, "left"), 0f)
: (hasExplicitRight ? parentSize.x - ParseCssPx(GetStyle(childStyle, "right"), 0f) - width : 0f);
top = hasExplicitTop
? ParseCssPx(GetStyle(childStyle, "top"), 0f)
: (hasExplicitBottom ? parentSize.y - ParseCssPx(GetStyle(childStyle, "bottom"), 0f) - height : 0f);
}
else if (isFlex)
{
left = cursorX + marginLeft;
top = marginTop;
cursorX = left + width + marginRight + gap;
}
else
{
left = marginLeft;
top = cursorY + marginTop;
cursorY = top + height + marginBottom;
}
result[child] = new HtmlLayout(left, top, width, height);
}
return result;
}
static void ApplyRectTransform(RectTransform rect, HtmlNode node, Dictionary<string, string> style, bool isRoot, HtmlLayout layoutOverride)
{
Vector2 design = GetDesignResolution(node);
rect.anchorMin = ParseVector2(GetAttr(node, "data-anchor-min", isRoot ? "0,0" : "0,1"), isRoot ? Vector2.zero : new Vector2(0, 1));
rect.anchorMax = ParseVector2(GetAttr(node, "data-anchor-max", isRoot ? "1,1" : "0,1"), isRoot ? Vector2.one : new Vector2(0, 1));
rect.pivot = ParseVector2(GetAttr(node, "data-pivot", "0.5,0.5"), new Vector2(0.5f, 0.5f));
if (isRoot)
{
rect.sizeDelta = design;
rect.anchoredPosition = Vector2.zero;
return;
}
float width = layoutOverride != null ? layoutOverride.Width : GetElementWidth(node, style, 160f);
float height = layoutOverride != null ? layoutOverride.Height : GetElementHeight(node, style, 48f);
float left = layoutOverride != null
? layoutOverride.Left
: (HasStyle(style, "right") ? design.x - ParseCssPx(GetStyle(style, "right"), 0f) - width : ParseCssPx(GetStyle(style, "left"), 0f));
float top = layoutOverride != null
? layoutOverride.Top
: (HasStyle(style, "bottom") ? design.y - ParseCssPx(GetStyle(style, "bottom"), 0f) - height : ParseCssPx(GetStyle(style, "top"), 0f));
rect.sizeDelta = new Vector2(width, height);
Vector2 anchorMin = rect.anchorMin;
Vector2 anchorMax = rect.anchorMax;
if (Approximately(anchorMin.x, anchorMax.x) && Approximately(anchorMin.y, anchorMax.y))
{
float x = left + width * rect.pivot.x - design.x * anchorMin.x;
float y = -(top + height * (1f - rect.pivot.y)) + design.y * (1f - anchorMin.y);
rect.anchoredPosition = new Vector2(x, y);
}
}
static Vector2 GetDesignResolution(HtmlNode node)
{
HtmlNode current = node;
while (current != null)
{
if (HasAttr(current, "data-design-width") && HasAttr(current, "data-design-height"))
{
return new Vector2(
ParseFloat(GetAttr(current, "data-design-width", "2048"), 2048f),
ParseFloat(GetAttr(current, "data-design-height", "1024"), 1024f));
}
current = current.Parent;
}
return new Vector2(2048, 1024);
}
static Dictionary<string, string> ParseUnityPrefabMeta(string html)
{
Dictionary<string, string> meta = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
Match match = Regex.Match(html, @"<script[^>]*id\s*=\s*[""']unity-prefab-meta[""'][^>]*>(?<json>.*?)</script>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (!match.Success)
return meta;
string json = match.Groups["json"].Value;
foreach (Match prop in Regex.Matches(json, @"""(?<key>prefabName|outputPrefabPath|htmlPath|spriteRoot)""\s*:\s*""(?<value>[^""]*)"""))
meta[prop.Groups["key"].Value] = prop.Groups["value"].Value;
return meta;
}
static void ApplyCommonMetadata(GameObject go, HtmlNode node)
{
if (HasAttr(node, "data-active-default"))
go.SetActive(ParseBool(GetAttr(node, "data-active-default", "true")));
int sibling = ParseInt(GetAttr(node, "data-sibling-index", "-1"), -1);
if (sibling >= 0)
go.transform.SetSiblingIndex(sibling);
}
static Sprite LoadSprite(string path)
{
if (string.IsNullOrEmpty(path))
return null;
return AssetDatabase.LoadAssetAtPath<Sprite>(NormalizeAssetPath(path));
}
static Image.Type ParseImageType(string value)
{
switch ((value ?? "").ToLowerInvariant())
{
case "sliced":
return Image.Type.Sliced;
case "filled":
return Image.Type.Filled;
case "tiled":
return Image.Type.Tiled;
default:
return Image.Type.Simple;
}
}
static Component AddComponentByTypeName(GameObject go, string typeName)
{
Type type = FindType(typeName);
if (type == null || !typeof(Component).IsAssignableFrom(type))
return null;
return go.AddComponent(type);
}
static Type FindType(string typeName)
{
Type type = Type.GetType(typeName);
if (type != null)
return type;
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
type = assembly.GetType(typeName);
if (type != null)
return type;
}
return null;
}
static bool HasTmpTextInChildren(Transform parent)
{
Type textType = FindType("TMPro.TextMeshProUGUI");
if (textType == null)
return false;
return parent.GetComponentsInChildren(textType, true).Length > 0;
}
static void SetObjectProperty(object target, string propertyName, object value)
{
if (target == null || value == null)
return;
Type type = target.GetType();
var property = type.GetProperty(propertyName);
if (property != null && property.CanWrite)
{
property.SetValue(target, value, null);
return;
}
var field = type.GetField(propertyName);
if (field != null)
field.SetValue(target, value);
}
static object ParseEnumProperty(object target, string propertyName, string value)
{
var property = target.GetType().GetProperty(propertyName);
Type enumType = property != null ? property.PropertyType : null;
if (enumType == null || !enumType.IsEnum)
return null;
try
{
return Enum.Parse(enumType, value, true);
}
catch
{
return Enum.GetValues(enumType).GetValue(0);
}
}
static object ParseTmpEnum(object target, string propertyName, string value)
{
if (target == null)
return null;
var property = target.GetType().GetProperty(propertyName);
Type enumType = property != null ? property.PropertyType : null;
if (enumType == null || !enumType.IsEnum)
return null;
try
{
return Enum.Parse(enumType, value, true);
}
catch
{
return null;
}
}
static object ParseTextAlignment(Component textComponent, string value)
{
var property = textComponent.GetType().GetProperty("alignment");
Type enumType = property != null ? property.PropertyType : FindType("TMPro.TextAlignmentOptions");
if (enumType == null || !enumType.IsEnum)
return null;
string enumName;
switch ((value ?? "").ToLowerInvariant())
{
case "left":
enumName = "Left";
break;
case "right":
enumName = "Right";
break;
case "middle":
case "midline":
enumName = "Midline";
break;
case "midlineleft":
enumName = "MidlineLeft";
break;
case "center":
default:
enumName = "Center";
break;
}
try
{
return Enum.Parse(enumType, enumName, true);
}
catch
{
return Enum.GetValues(enumType).GetValue(0);
}
}
static object ParseFontStyle(Component textComponent, HtmlNode node, Dictionary<string, string> style)
{
var property = textComponent.GetType().GetProperty("fontStyle");
Type enumType = property != null ? property.PropertyType : FindType("TMPro.FontStyles");
if (enumType == null || !enumType.IsEnum)
return null;
bool bold = IsBoldFont(node, style);
string enumName = bold ? "Bold" : "Normal";
try
{
return Enum.Parse(enumType, enumName, true);
}
catch
{
return Enum.GetValues(enumType).GetValue(0);
}
}
static bool IsBoldFont(HtmlNode node, Dictionary<string, string> style)
{
string weight = GetAttr(node, "data-font-weight", GetStyle(style, "font-weight"));
return weight.Equals("bold", StringComparison.OrdinalIgnoreCase)
|| weight.Equals("bolder", StringComparison.OrdinalIgnoreCase)
|| ParseInt(weight, 400) >= 600;
}
static Color ParseColor(string value, Color fallback)
{
if (string.IsNullOrEmpty(value))
return fallback;
value = value.Trim();
if (value.StartsWith("#"))
{
if (ColorUtility.TryParseHtmlString(value, out Color color))
return color;
}
Match rgba = Regex.Match(value, @"rgba?\((?<r>[\d.]+),\s*(?<g>[\d.]+),\s*(?<b>[\d.]+)(,\s*(?<a>[\d.]+))?\)");
if (rgba.Success)
{
float r = ParseFloat(rgba.Groups["r"].Value, 255f) / 255f;
float g = ParseFloat(rgba.Groups["g"].Value, 255f) / 255f;
float b = ParseFloat(rgba.Groups["b"].Value, 255f) / 255f;
float a = rgba.Groups["a"].Success ? ParseFloat(rgba.Groups["a"].Value, 1f) : 1f;
return new Color(r, g, b, a);
}
return fallback;
}
static string GetInnerText(HtmlNode node)
{
StringBuilder sb = new StringBuilder();
if (!string.IsNullOrEmpty(node.Text))
sb.Append(node.Text);
foreach (HtmlNode child in node.Children)
sb.Append(GetInnerText(child));
return sb.ToString().Trim();
}
static string GetAttr(HtmlNode node, string key, string fallback)
{
if (node != null && node.Attributes.TryGetValue(key, out string value))
return value;
return fallback;
}
static string GetFirstAttr(HtmlNode node, params string[] keys)
{
foreach (string key in keys)
{
string value = GetAttr(node, key, "");
if (!string.IsNullOrEmpty(value))
return value;
}
return "";
}
static bool HasAttr(HtmlNode node, string key)
{
return node != null && node.Attributes.ContainsKey(key);
}
static string GetStyle(Dictionary<string, string> style, string key)
{
return style.TryGetValue(key, out string value) ? value : "";
}
static bool HasStyle(Dictionary<string, string> style, string key)
{
return style.ContainsKey(key) && !string.IsNullOrEmpty(style[key]);
}
static string GetCssTextAlign(Dictionary<string, string> style)
{
return style.TryGetValue("text-align", out string value) ? value : "Center";
}
static string GetFirstValue(HtmlNode node, Dictionary<string, string> style, string attrKey, string styleKey)
{
string attr = GetAttr(node, attrKey, "");
if (!string.IsNullOrEmpty(attr))
return attr;
return GetStyle(style, styleKey);
}
static string GetMetaValue(Dictionary<string, string> meta, string key)
{
return meta.TryGetValue(key, out string value) ? value : "";
}
static float ParseCssPx(string value, float fallback)
{
if (string.IsNullOrEmpty(value))
return fallback;
Match match = Regex.Match(value, @"-?[\d.]+");
if (!match.Success)
return fallback;
return ParseFloat(match.Value, fallback);
}
static float GetElementWidth(HtmlNode node, Dictionary<string, string> style, float fallback)
{
if (HasStyle(style, "width"))
return ParseCssPx(GetStyle(style, "width"), fallback);
string unityType = GetAttr(node, "data-unity-type", "");
if (unityType == "TextMeshProUGUI" || unityType == "Button")
{
string text = HtmlDecode(GetAttr(node, "data-text", GetInnerText(node))).Trim();
if (!string.IsNullOrEmpty(text))
{
float fontSize = ParseFloat(GetFirstValue(node, style, "data-font-size", "font-size"), 24f);
return EstimateTextWidth(text, fontSize, IsBoldFont(node, style));
}
}
return fallback;
}
static float GetElementHeight(HtmlNode node, Dictionary<string, string> style, float fallback)
{
if (HasStyle(style, "height"))
return ParseCssPx(GetStyle(style, "height"), fallback);
string unityType = GetAttr(node, "data-unity-type", "");
if (unityType == "TextMeshProUGUI")
{
float fontSize = ParseFloat(GetFirstValue(node, style, "data-font-size", "font-size"), 24f);
return ParseLineHeight(GetStyle(style, "line-height"), fontSize);
}
return fallback;
}
static float GetDefaultChildWidth(HtmlNode node, Vector2 parentSize)
{
string unityType = GetAttr(node, "data-unity-type", "");
if (unityType == "TextMeshProUGUI" && parentSize.x > 1f)
return parentSize.x;
return 160f;
}
static float GetDefaultChildHeight(HtmlNode node, Vector2 parentSize)
{
string unityType = GetAttr(node, "data-unity-type", "");
if (unityType == "TextMeshProUGUI" && parentSize.y > 1f)
return parentSize.y;
return 48f;
}
static float ParseLineHeight(string value, float fontSize)
{
if (string.IsNullOrEmpty(value))
return fontSize * 1.25f;
value = value.Trim();
if (value.EndsWith("px", StringComparison.OrdinalIgnoreCase))
return ParseCssPx(value, fontSize * 1.25f);
float multiplier = ParseFloat(value, -1f);
if (multiplier > 0f && multiplier < 10f)
return fontSize * multiplier;
return fontSize * 1.25f;
}
static float EstimateTextWidth(string text, float fontSize, bool bold)
{
float units = 0f;
foreach (char c in text)
{
if (char.IsWhiteSpace(c))
units += 0.35f;
else if (c < 128)
units += 0.62f;
else
units += 1f;
}
float boldScale = bold ? 1.16f : 1f;
float padding = Mathf.Max(12f, fontSize * 0.5f);
return Mathf.Max(fontSize, Mathf.Ceil(units * fontSize * boldScale + padding));
}
static float ParseFloat(string value, float fallback)
{
if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float result))
return result;
return fallback;
}
static int ParseInt(string value, int fallback)
{
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int result))
return result;
return fallback;
}
static bool ParseBool(string value)
{
return value != null && (value.Equals("true", StringComparison.OrdinalIgnoreCase) || value == "1");
}
static Vector2 ParseVector2(string value, Vector2 fallback)
{
if (string.IsNullOrEmpty(value))
return fallback;
string[] parts = value.Split(',');
if (parts.Length != 2)
return fallback;
return new Vector2(ParseFloat(parts[0], fallback.x), ParseFloat(parts[1], fallback.y));
}
static bool Approximately(float a, float b)
{
return Mathf.Abs(a - b) < 0.0001f;
}
static string NormalizeAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
return path;
return path.Replace('\\', '/').Trim();
}
static void EnsureAssetDirectory(string assetDir)
{
assetDir = NormalizeAssetPath(assetDir);
if (string.IsNullOrEmpty(assetDir))
return;
string fullPath = Path.Combine(Directory.GetParent(Application.dataPath).FullName, assetDir);
if (!Directory.Exists(fullPath))
Directory.CreateDirectory(fullPath);
AssetDatabase.Refresh();
}
static string HtmlDecode(string text)
{
if (string.IsNullOrEmpty(text))
return "";
return text
.Replace("&nbsp;", " ")
.Replace("&lt;", "<")
.Replace("&gt;", ">")
.Replace("&quot;", "\"")
.Replace("&#39;", "'")
.Replace("&amp;", "&");
}
static bool IsVoidTag(string tag)
{
switch (tag)
{
case "area":
case "base":
case "br":
case "col":
case "embed":
case "hr":
case "img":
case "input":
case "link":
case "meta":
case "param":
case "source":
case "track":
case "wbr":
return true;
default:
return false;
}
}
class HtmlDocumentData
{
public HtmlNode Root;
public Dictionary<string, Dictionary<string, string>> CssRules = new Dictionary<string, Dictionary<string, string>>();
}
class HtmlLayout
{
public float Left;
public float Top;
public float Width;
public float Height;
public HtmlLayout(float left, float top, float width, float height)
{
Left = left;
Top = top;
Width = width;
Height = height;
}
}
class HtmlNode
{
public string TagName;
public string Text = "";
public HtmlNode Parent;
public Dictionary<string, string> Attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public List<HtmlNode> Children = new List<HtmlNode>();
}
}
[InitializeOnLoad]
public static class XWorldJsonPrefabFocusAutoConverter
{
static XWorldJsonPrefabFocusAutoConverter()
{
EditorApplication.focusChanged += OnFocusChanged;
}
static void OnFocusChanged(bool hasFocus)
{
if (!hasFocus)
return;
EditorApplication.delayCall -= ConvertAfterFocus;
EditorApplication.delayCall += ConvertAfterFocus;
}
static void ConvertAfterFocus()
{
XWorldUtil.AutoConvertChangedJsonPrefabsOnEditorFocus();
}
}