Land Explorer in HomeCamp with boot loading, journal UI, and CDN publish tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-11-04 16:18:01 +08:00
co-authored by Cursor
parent 9eec364f30
commit 80678ec499
405 changed files with 48053 additions and 420302 deletions
+8 -1
View File
@@ -26,7 +26,14 @@ public static class SetupBootScene
}
var bootGo = new GameObject("Boot");
bootGo.AddComponent<AOT.Boot.BootDriver>();
var driver = bootGo.AddComponent<AOT.Boot.BootDriver>();
var loading = AssetDatabase.LoadAssetAtPath<GameObject>(AIGame.EditorTools.BuildBootLoading.PrefabPath);
if (loading != null)
{
var so = new SerializedObject(driver);
so.FindProperty("loadingPrefab").objectReferenceValue = loading.GetComponent<AOT.UI.BootPatchView>();
so.ApplyModifiedPropertiesWithoutUndo();
}
Directory.CreateDirectory("Assets/Scenes");
UnityEditor.SceneManagement.EditorSceneManager.SaveScene(scene, ScenePath);
@@ -0,0 +1,102 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using AOT.Boot;
using AOT.UI;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.TextCore.LowLevel;
namespace AIGame.EditorTools
{
public static class BuildBootLoading
{
public const string PrefabPath = "Assets/AOT/UI/UI_BootLoading.prefab";
public const string FontPath = "Assets/AOT/UI/BootChinese.asset";
const string BootScene = "Assets/Scenes/Boot.unity";
[MenuItem("AIGame/Boot/Build Loading Screen")]
public static string Build()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play Mode first.");
for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++)
if (UnityEngine.SceneManagement.SceneManager.GetSceneAt(i).isDirty)
throw new InvalidOperationException("Save or discard existing scene changes first.");
BuildFont();
string json = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Doc/UIPrefabCreater/UI_BootLoading.json"));
var path = JsonUIPrefabBuilder.BuildFromJsonFile(json);
if (path != PrefabPath) throw new InvalidOperationException("Loading JSON generation failed.");
var setup = EditorSceneManager.GetSceneManagerSetup();
try
{
var scene = EditorSceneManager.OpenScene(BootScene, OpenSceneMode.Single);
var driver = scene.GetRootGameObjects().SelectMany(g => g.GetComponentsInChildren<BootDriver>(true)).Single();
var so = new SerializedObject(driver);
so.FindProperty("loadingPrefab").objectReferenceValue = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath).GetComponent<BootPatchView>();
so.ApplyModifiedPropertiesWithoutUndo();
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
}
finally { EditorSceneManager.RestoreSceneManagerSetup(setup); }
if (!Application.HasProLicense()) throw new InvalidOperationException("Splash configuration requires a supported Unity license.");
PlayerSettings.SplashScreen.show = false;
PlayerSettings.SplashScreen.showUnityLogo = false;
AssetDatabase.SaveAssets();
return "PASS: JSON loading prefab, static Chinese glyphs, Boot reference and splash disabled; Player not rebuilt.";
}
public static string ValidateConfigured()
{
if (PlayerSettings.SplashScreen.show) return "启动 Splash 仍开启,请先执行 AIGame/Boot/Build Loading Screen";
if (!AssetDatabase.GetDependencies(BootScene, true).Contains(PrefabPath))
return "Boot 尚未引用启动 Prefab,请先执行 AIGame/Boot/Build Loading Screen";
return null;
}
static void BuildFont()
{
if (!AssetDatabase.IsValidFolder("Assets/AOT/UI")) AssetDatabase.CreateFolder("Assets/AOT", "UI");
const string words = "萌友防线小镇营地集结伙伴·开启冒险首次进入需要下载资源请稍候正在准备…,。检查更新系统布置就绪即将出发加载失败重新游戏连接网络等待秒完成本热代码主界面包回退编辑器直通跳过补丁请求版本超时";
string characters = new string((words + new string(Enumerable.Range(32, 95).Select(i => (char)i).ToArray())).Distinct().ToArray());
var existing = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(FontPath);
if (existing != null)
{
if (!existing.HasCharacters(characters))
{
// Extend the authored subset without changing its GUID or runtime mode.
existing.atlasPopulationMode = AtlasPopulationMode.Dynamic;
try
{
if (!existing.TryAddCharacters(characters, out string missingCharacters))
throw new InvalidOperationException("Boot font missing: " + missingCharacters);
}
finally { existing.atlasPopulationMode = AtlasPopulationMode.Static; }
EditorUtility.SetDirty(existing);
foreach (var texture in existing.atlasTextures) EditorUtility.SetDirty(texture);
AssetDatabase.SaveAssetIfDirty(existing);
}
return;
}
var source = AssetDatabase.LoadAssetAtPath<Font>("Assets/Bundles/UI/Font/siyuan_CN_Bold.otf");
var font = TMP_FontAsset.CreateFontAsset(source, 58, 6, GlyphRenderMode.SDFAA, 1024, 1024, AtlasPopulationMode.Dynamic, false);
if (!font.TryAddCharacters(characters, out string missing)) throw new InvalidOperationException("Boot font missing: " + missing);
font.atlasPopulationMode = AtlasPopulationMode.Static;
font.fallbackFontAssetTable = new System.Collections.Generic.List<TMP_FontAsset>();
font.name = "BootChinese";
AssetDatabase.CreateAsset(font, FontPath);
font.material.name = "BootChinese Material";
AssetDatabase.AddObjectToAsset(font.material, font);
foreach (var texture in font.atlasTextures)
{
texture.name = "BootChinese Atlas";
AssetDatabase.AddObjectToAsset(texture, font);
}
EditorUtility.SetDirty(font);
AssetDatabase.SaveAssetIfDirty(font);
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 607a67b747362dd49a9b7ffc63efc84a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -611,6 +611,12 @@ namespace AIGame.EditorTools
{
TextMeshProUGUI t = GetOrAdd<TextMeshProUGUI>(go);
ApplyDefaultFont(t);
string fontAssetPath = (string)comp["fontAsset"];
if (!string.IsNullOrEmpty(fontAssetPath))
{
t.font = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(fontAssetPath);
if (t.font == null) throw new InvalidOperationException("TMP font asset not found: " + fontAssetPath);
}
t.text = (string)comp["text"] ?? string.Empty;
if (comp["fontSize"] != null) t.fontSize = (float)comp["fontSize"];
t.fontStyle = string.Equals((string)comp["fontStyle"], "Bold", StringComparison.OrdinalIgnoreCase) ? FontStyles.Bold : FontStyles.Normal;
@@ -11,6 +11,12 @@ namespace AIGame.EditorTools
public static class UiSpriteNineSliceRules
{
private const string CommonRoot = "Assets/Bundles/UI/Texture/Common/";
private const string JournalRoot = "Assets/Bundles/UI/Texture/Journal/";
private static readonly HashSet<string> JournalControls = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"journal_card", "journal_hero", "journal_gold_card", "journal_primary_normal", "journal_primary_pressed",
"journal_secondary_normal", "journal_secondary_pressed", "journal_disabled", "journal_progress_bg", "journal_progress_fill"
};
// W,H,L,R,T,B:设计尺寸与边界;实际切图按尺寸换算,并包含 2px 透明留边。
private static readonly Dictionary<string, int[]> Rules = new Dictionary<string, int[]>(StringComparer.OrdinalIgnoreCase)
{
@@ -46,6 +52,8 @@ namespace AIGame.EditorTools
{
if (string.IsNullOrEmpty(path)) return false;
path = path.Replace('\\', '/');
if (string.Equals(Path.GetDirectoryName(path)?.Replace('\\', '/'), JournalRoot.TrimEnd('/'), StringComparison.OrdinalIgnoreCase))
return JournalControls.Contains(Path.GetFileNameWithoutExtension(path));
return path.StartsWith(CommonRoot, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Path.GetDirectoryName(path)?.Replace('\\', '/'), CommonRoot.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)
&& Rules.ContainsKey(Path.GetFileNameWithoutExtension(path));
@@ -53,6 +61,8 @@ namespace AIGame.EditorTools
public static Vector4 GetBorder(string path, int width, int height)
{
if (JournalControls.Contains(Path.GetFileNameWithoutExtension(path)))
return new Vector4(30f * width / 128, 32f * height / 128, 30f * width / 128, 28f * height / 128);
int[] rule = Rules[Path.GetFileNameWithoutExtension(path)];
float sx = Mathf.Max(1, width - 4) / (float)rule[0];
float sy = Mathf.Max(1, height - 4) / (float)rule[1];
@@ -106,6 +116,13 @@ namespace AIGame.EditorTools
importer.SaveAndReimport();
changed++;
}
foreach (string name in JournalControls)
{
var importer = AssetImporter.GetAtPath(JournalRoot + name + ".png") as TextureImporter;
if (importer == null || !Configure(importer)) continue;
importer.SaveAndReimport();
changed++;
}
return changed;
}
}
@@ -160,6 +160,7 @@ namespace AOT.Editor.WeChat
try
{
WeChatLoadingCover.Configure(mini);
JObject.Parse(File.ReadAllText(Path.Combine(mini, "game.json")));
string miniConfigPath = Path.Combine(mini, "project.config.json");
string rootConfigPath = Path.Combine(dst, "project.config.json");
@@ -196,6 +197,8 @@ namespace AOT.Editor.WeChat
static string ValidateBootOnly()
{
string startupError = AIGame.EditorTools.BuildBootLoading.ValidateConfigured();
if (startupError != null) return startupError;
if (!File.Exists(BootScene))
return "缺少场景 " + BootScene;
var enabled = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(scene => scene.path).ToArray();
@@ -0,0 +1,78 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using AIGame.EditorTools;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace AOT.Editor.WeChat
{
public static class WeChatLoadingCover
{
public static void Configure(string mini)
{
string repo = Path.GetFullPath(Path.Combine(Application.dataPath,"../.."));
string module = Path.Combine(repo,"Tools/WeChat/startup-loading.js");
string gamePath = Path.Combine(mini,"game.js");
string game = File.ReadAllText(gamePath);
const string import = "import startupLoading from './startup-loading';";
if (!game.Contains(import))
{
var pattern = new Regex(@"loadingPageConfig:\s*\{.*?(?=\r?\n\s{8}\}\);)",RegexOptions.Singleline);
if (pattern.Matches(game).Count != 1) throw new InvalidOperationException("Cannot identify SDK loading-page configuration.");
game = pattern.Replace(game,"loadingPageConfig: startupLoading,",1);
game = import + Environment.NewLine + game;
}
if (!game.Contains("loadingPageConfig: startupLoading,")) throw new InvalidOperationException("SDK cover import has no configuration.");
string output = Path.Combine(repo,"Tools/output/startup_presentation/startup-cover.png");
Render(output);
Directory.CreateDirectory(Path.Combine(mini,"images"));
File.Copy(output,Path.Combine(mini,"images/startup-cover.png"),true);
File.Copy(module,Path.Combine(mini,"startup-loading.js"),true);
File.WriteAllText(gamePath,game,new UTF8Encoding(false));
}
static void Render(string path)
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(BuildBootLoading.PrefabPath);
if (prefab == null) throw new InvalidOperationException("Build the local loading prefab first.");
var active = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene,NewSceneMode.Additive);
var previous = RenderTexture.active;
var target = new RenderTexture(720,1280,24);
var pixels = new Texture2D(720,1280,TextureFormat.RGB24,false);
try
{
var root = (GameObject)PrefabUtility.InstantiatePrefab(prefab,scene);
root.transform.Find("LoadingCard").gameObject.SetActive(false);
var cameraObject = new GameObject("CoverRenderCamera",typeof(Camera));
UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(cameraObject,scene);
var camera = cameraObject.GetComponent<Camera>();
camera.targetTexture=target;camera.cullingMask=1<<LayerMask.NameToLayer("UI");
camera.clearFlags=CameraClearFlags.SolidColor;camera.backgroundColor=new Color(.54f,.72f,.76f);
var canvas = root.GetComponent<Canvas>();
canvas.renderMode=RenderMode.ScreenSpaceCamera;canvas.worldCamera=camera;canvas.planeDistance=1;
Canvas.ForceUpdateCanvases();
foreach(var text in root.GetComponentsInChildren<TMP_Text>()) text.ForceMeshUpdate();
camera.Render();RenderTexture.active=target;
pixels.ReadPixels(new Rect(0,0,720,1280),0,0);pixels.Apply();
var colors = pixels.GetPixels32(); var first=colors[0]; int varied=0;
foreach(var color in colors)
if(Mathf.Abs(color.r-first.r)+Mathf.Abs(color.g-first.g)+Mathf.Abs(color.b-first.b)>30) varied++;
if(varied<colors.Length/10) throw new InvalidOperationException("Loading cover render is blank.");
Directory.CreateDirectory(Path.GetDirectoryName(path));File.WriteAllBytes(path,pixels.EncodeToPNG());
}
finally
{
RenderTexture.active=previous;EditorSceneManager.CloseScene(scene,true);
if (active.IsValid() && active.isLoaded) UnityEngine.SceneManagement.SceneManager.SetActiveScene(active);
target.Release();UnityEngine.Object.DestroyImmediate(target);UnityEngine.Object.DestroyImmediate(pixels);
}
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bb291bc5c1cac5646885b8c13a0013a2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -107,6 +107,16 @@ namespace AOT.Editor.YooAssetTools
AssetPathToGUIDOptions.OnlyExistingAssets))).ToArray())
BundleCollectorSettingData.RemoveCollector(group, collector);
const string campScene = "Assets/Bundles/Scenes/Town/HomeCamp/HomeCamp.unity";
if (string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(campScene, AssetPathToGUIDOptions.OnlyExistingAssets)))
throw new InvalidOperationException("HomeCamp scene is missing: " + campScene);
if (!group.Collectors.Any(c => c.CollectPath == campScene))
BundleCollectorSettingData.CreateCollector(group, new BundleCollector {
CollectPath = campScene, CollectorGUID = AssetDatabase.AssetPathToGUID(campScene),
CollectorType = ECollectorType.MainAssetCollector, AddressRuleName = nameof(AddressByFileName),
PackRuleName = nameof(PackSeparately), FilterRuleName = nameof(CollectScene), AssetTags = "art"
});
package.CheckConfigError();
BundleCollectorSettingData.SaveFile();
return "ok scene entries=" + group.Collectors.Count;
@@ -173,7 +173,7 @@ namespace AOT.Editor.YooAssetTools
var prod = VersionJson(packVersion, resVersion, PublicCdnRoot);
WriteText(Path.Combine(CdnRoot, Channel, "version.json"), local);
WriteText(Path.Combine(CdnRoot, Channel, "version.prod.json"), prod);
WriteText(Path.Combine(Application.streamingAssetsPath, "cdn", "version.json"), local);
WriteText(Path.Combine(Application.streamingAssetsPath, "cdn", "version.json"), prod);
}
static string VersionJson(string packVersion, string resVersion, string cdnRoot)
@@ -0,0 +1,111 @@
#if UNITY_EDITOR
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
using Debug = UnityEngine.Debug;
namespace AOT.Editor.YooAssetTools
{
/// <summary>Explicit, incremental publication of the current WebGL resource packages.</summary>
public static class CdnPublisher
{
static bool _busy;
static string Repo => Path.GetFullPath(Path.Combine(Application.dataPath, "../.."));
static string Config => Path.Combine(Repo, "Tools/cdn_publish.local.json");
static string LogPath => Path.Combine(Repo, "Tools/output/cdn_publish/last-unity.log");
public static string Status { get; private set; } = "idle";
[MenuItem("AIGame/CDN/Check SSH Connection")]
public static async void CheckSshMenu() => await Guard(() => RunPython("check"));
[MenuItem("AIGame/CDN/Preview Upload Changes")]
public static async void PreviewMenu() => await Guard(() => RunPython("plan --compare"));
[MenuItem("AIGame/CDN/Upload Staged Resources")]
public static async void UploadMenu() => await Guard(() => RunPython("upload"));
[MenuItem("AIGame/CDN/Build And Upload (Art + HotUpdate)")]
public static async void BuildAndUploadMenu() => await Guard(async () => {
await RunPython("check");
BuildPackages();
await RunPython("upload");
});
[MenuItem("AIGame/CDN/Prepare SSH Key")]
public static async void PrepareKeyMenu() => await Guard(() => RunPython("init-key"));
[MenuItem("AIGame/CDN/Open Publish Folder")]
public static void OpenPublishFolder()
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath));
EditorUtility.RevealInFinder(Path.GetDirectoryName(LogPath));
}
public static string BuildPackages()
{
if (EditorApplication.isPlaying || EditorApplication.isCompiling)
throw new InvalidOperationException("Stop Play Mode and finish compiling before building CDN resources.");
if (EditorUserBuildSettings.activeBuildTarget != BuildTarget.WebGL)
throw new InvalidOperationException("CDN packages must be built for WebGL. Select that target first.");
Status = "building Art";
var art = BuildAndStageArt.BuildConfiguredAndStage();
if (!art.StartsWith("ok", StringComparison.Ordinal)) throw new InvalidOperationException(art);
Debug.Log("[CDN] " + art);
Status = "building HotUpdate";
var raw = BuildAndStageHotUpdate.BuildAndStage();
if (!raw.StartsWith("ok", StringComparison.Ordinal)) throw new InvalidOperationException(raw);
Debug.Log("[CDN] " + raw);
Status = "packages staged";
return art + " | " + raw;
}
static async Task Guard(Func<Task> action)
{
if (_busy) { Debug.LogWarning("[CDN] A publish command is already running."); return; }
_busy = true;
try { await action(); Status = "completed"; Debug.Log("[CDN] Completed. Log: " + LogPath); }
catch (Exception error) { Status = "failed"; Debug.LogError("[CDN] " + error.Message); }
finally { _busy = false; }
}
static async Task RunPython(string command)
{
if (!File.Exists(Config))
File.Copy(Path.Combine(Repo, "Tools/cdn_publish.example.json"), Config);
Status = command;
var pending = new ConcurrentQueue<string>();
var start = new ProcessStartInfo {
FileName = EditorPrefs.GetString("AIGame.CdnPython", "python"),
Arguments = "\"" + Path.Combine(Repo, "Tools/publish_cdn.py") + "\" " + command + " --config \"" + Config + "\"",
WorkingDirectory = Repo, UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8
};
start.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8";
start.EnvironmentVariables["PYTHONUNBUFFERED"] = "1";
Directory.CreateDirectory(Path.GetDirectoryName(LogPath));
using (var log = new StreamWriter(LogPath, true, new UTF8Encoding(false)))
using (var process = new Process { StartInfo = start })
{
log.WriteLine(DateTime.UtcNow.ToString("o") + " " + command);
process.OutputDataReceived += (_, e) => { if (e.Data != null) pending.Enqueue(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) pending.Enqueue(e.Data); };
process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine();
while (!process.HasExited)
{
while (pending.TryDequeue(out var line)) { Debug.Log("[CDN] " + line); log.WriteLine(line); }
log.Flush(); await Task.Delay(150);
}
process.WaitForExit();
while (pending.TryDequeue(out var line)) { Debug.Log("[CDN] " + line); log.WriteLine(line); }
if (process.ExitCode != 0) throw new InvalidOperationException("Publish command failed. See " + LogPath);
}
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fe8f0110cd9a3084eb1da66ea0ee89c2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: