#nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using Demo2.Runtime; using ShrinkTutorial; using TMPro; using UnityEditor; using UnityEditor.Build.Reporting; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.SceneManagement; using UnityEngine.UIElements; namespace Demo2.Editor { public static class Demo2SceneBuilder { private const string Root = "Assets/Demos/Demo2"; private const string Art = Root + "/Art"; private const string Ui = Root + "/UI"; private const string ResourceRoot = Root + "/Resources"; private const string PrefabPath = Root + "/Prefabs/Demo2Root.prefab"; private const string ScenePath = Root + "/Demo2.unity"; private const string UxmlPath = Ui + "/Demo2.uxml"; private const string UssPath = Ui + "/Demo2.uss"; private const string PanelSettingsPath = Ui + "/Demo2PanelSettings.asset"; private const string ThemePath = Ui + "/Demo2RuntimeTheme.tss"; private const string FontPath = Art + "/NotoSansSC-Variable.ttf"; private const string TutorialFontPath = Art + "/TutorialChinese SDF.asset"; private static readonly string[] MachineNames = { "miner", "conveyor", "splitter", "smelter", "assembler", "loader", "turret", "generator" }; private static readonly string[] TutorialAnchorIds = { "demo2.miner", "demo2.conveyor", "demo2.grid", "demo2.smelter", "demo2.recipe", "demo2.turret", "demo2.ready", "demo2.reward" }; private static Sprite? _floor; private static Sprite[] _machineSprites = Array.Empty(); private static Texture2D? _itemTexture; private static Font? _font; private static TMP_FontAsset? _tutorialFont; [MenuItem("ShrinkSDK/Demo2/创建规则工厂 Demo")] public static void Build() { EnsureDirectories(); ConfigureArt(); LoadAssets(); BuildTutorialAssets(); var panelSettings = BuildPanelSettings(); var root = BuildRoot(panelSettings); PrefabUtility.SaveAsPrefabAsset(root, PrefabPath); UnityEngine.Object.DestroyImmediate(root); var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); var prefab = AssetDatabase.LoadAssetAtPath(PrefabPath) ?? throw new InvalidOperationException("Demo2 root prefab was not created."); PrefabUtility.InstantiatePrefab(prefab, scene); var eventSystem = new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule)); Undo.RegisterCreatedObjectUndo(eventSystem, "Create Demo2 EventSystem"); EditorSceneManager.MarkSceneDirty(scene); EditorSceneManager.SaveScene(scene, ScenePath); AddSceneToBuildSettings(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Selection.activeObject = AssetDatabase.LoadAssetAtPath(ScenePath); Debug.Log($"[Demo2] Created UI Toolkit prefab {PrefabPath} and scene {ScenePath}"); } [MenuItem("ShrinkSDK/Demo2/构建 Headless Server")] public static void BuildHeadlessServer() { if (!File.Exists(Path.GetFullPath(ScenePath))) Build(); var output = Path.GetFullPath("Builds/Demo2Headless/Demo2Server.exe"); Directory.CreateDirectory(Path.GetDirectoryName(output)!); var options = new BuildPlayerOptions { scenes = new[] { ScenePath }, locationPathName = output, target = BuildTarget.StandaloneWindows64, subtarget = (int)StandaloneBuildSubtarget.Server, options = BuildOptions.CleanBuildCache }; var report = BuildPipeline.BuildPlayer(options); if (report.summary.result != BuildResult.Succeeded) throw new InvalidOperationException("Demo2 headless build failed: " + report.summary.result); Debug.Log($"[Demo2] Headless server built: {output} ({report.summary.totalSize} bytes)"); } private static GameObject BuildRoot(PanelSettings panelSettings) { var root = new GameObject("Demo2Root", typeof(Demo2DemoController), typeof(Demo2HeadlessBootstrap)); var cameraObject = new GameObject("WorldCamera", typeof(Camera), typeof(AudioListener)); cameraObject.tag = "MainCamera"; cameraObject.transform.SetParent(root.transform, false); cameraObject.transform.position = new Vector3(16, 10, -20); var camera = cameraObject.GetComponent(); camera.orthographic = true; camera.orthographicSize = 11.6f; camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = Hex("101419"); var factoryWorld = new GameObject("FactoryWorld"); factoryWorld.transform.SetParent(root.transform, false); var floorObject = new GameObject("FactoryFloor", typeof(SpriteRenderer)); floorObject.transform.SetParent(factoryWorld.transform, false); floorObject.transform.position = new Vector3(16, 10, 1); var floorRenderer = floorObject.GetComponent(); floorRenderer.sprite = _floor; floorRenderer.sortingOrder = -10; if (_floor != null) { var size = _floor.bounds.size; floorObject.transform.localScale = new Vector3(32f / size.x, 20f / size.y, 1); } var proxyObject = new GameObject("MachineProxyPool", typeof(Demo2MachineProxyPool)); proxyObject.transform.SetParent(factoryWorld.transform, false); var proxyPool = proxyObject.GetComponent(); proxyPool.SetSprites(_machineSprites); var itemObject = new GameObject("ItemBatchRenderer", typeof(Demo2ItemBatchRenderer)); itemObject.transform.SetParent(factoryWorld.transform, false); var itemRenderer = itemObject.GetComponent(); SetObject(itemRenderer, "itemTexture", _itemTexture); var uiObject = new GameObject("UI", typeof(UIDocument)); uiObject.transform.SetParent(root.transform, false); var document = uiObject.GetComponent(); document.panelSettings = panelSettings; document.visualTreeAsset = AssetDatabase.LoadAssetAtPath(UxmlPath) ?? throw new InvalidOperationException("Demo2 UXML is missing."); document.sortingOrder = 0; var tutorialAnchorRoot = CreateTutorialAnchors(root.transform); var controller = root.GetComponent(); SetObject(controller, "worldCamera", camera); SetObject(controller, "proxyPool", proxyPool); SetObject(controller, "itemBatchRenderer", itemRenderer); SetObject(controller, "uiDocument", document); SetObject(controller, "uiStyleSheet", AssetDatabase.LoadAssetAtPath(UssPath)); SetObject(controller, "titleBackground", _floor); SetObject(controller, "uiFont", _font); SetObject(controller, "tutorialFont", _tutorialFont); SetObject(controller, "tutorialAnchorRoot", tutorialAnchorRoot); SetObjectArray(controller, "machineIcons", _machineSprites); return root; } private static RectTransform CreateTutorialAnchors(Transform parent) { var root = new GameObject("TutorialAnchors", typeof(RectTransform)).GetComponent(); root.SetParent(parent, false); root.anchorMin = Vector2.zero; root.anchorMax = Vector2.zero; root.pivot = Vector2.zero; root.anchoredPosition = Vector2.zero; root.sizeDelta = new Vector2(1920, 1080); foreach (var id in TutorialAnchorIds) { var child = new GameObject(id, typeof(RectTransform), typeof(ShrinkTutorialAnchor)).GetComponent(); child.SetParent(root, false); child.anchorMin = Vector2.zero; child.anchorMax = Vector2.zero; child.pivot = Vector2.zero; child.sizeDelta = new Vector2(64, 64); child.GetComponent().SetAnchorId(id); child.gameObject.SetActive(false); } return root; } private static PanelSettings BuildPanelSettings() { DeleteAssetIfExists(PanelSettingsPath); var settings = ScriptableObject.CreateInstance(); settings.name = "Demo2PanelSettings"; settings.themeStyleSheet = AssetDatabase.LoadAssetAtPath(ThemePath) ?? throw new InvalidOperationException("Demo2 runtime theme is missing."); settings.scaleMode = PanelScaleMode.ScaleWithScreenSize; settings.referenceResolution = new Vector2Int(1920, 1080); settings.screenMatchMode = PanelScreenMatchMode.MatchWidthOrHeight; settings.match = 0.5f; settings.sortingOrder = 0; settings.clearColor = false; AssetDatabase.CreateAsset(settings, PanelSettingsPath); if (AssetDatabase.IsValidFolder("Assets/UI Toolkit")) AssetDatabase.DeleteAsset("Assets/UI Toolkit"); return settings; } private static void ConfigureArt() { AssetDatabase.Refresh(); foreach (var path in AssetDatabase.FindAssets("t:Texture2D", new[] { Art }).Select(AssetDatabase.GUIDToAssetPath).Where(path => path.EndsWith(".png", StringComparison.OrdinalIgnoreCase))) { if (AssetImporter.GetAtPath(path) is not TextureImporter importer) continue; importer.textureType = TextureImporterType.Sprite; importer.spriteImportMode = SpriteImportMode.Single; importer.spritePixelsPerUnit = path.EndsWith("factory-floor.png", StringComparison.OrdinalIgnoreCase) ? 64 : 1024; importer.mipmapEnabled = false; importer.alphaIsTransparency = true; importer.textureCompression = TextureImporterCompression.CompressedHQ; importer.maxTextureSize = path.EndsWith("factory-floor.png", StringComparison.OrdinalIgnoreCase) ? 2048 : 1024; importer.SaveAndReimport(); } if (AssetDatabase.LoadAssetAtPath(FontPath) == null) { const string source = "Assets/Demos/ReplacedPerson/Art/NotoSansSC-Variable.ttf"; if (!AssetDatabase.CopyAsset(source, FontPath)) throw new InvalidOperationException("Could not copy the Chinese UI Toolkit font."); } if (AssetDatabase.LoadAssetAtPath(TutorialFontPath) == null) { const string source = "Assets/Demos/ReplacedPerson/Art/NotoSansSC SDF.asset"; if (!AssetDatabase.CopyAsset(source, TutorialFontPath)) throw new InvalidOperationException("Could not copy the Chinese tutorial TMP font."); } } private static void LoadAssets() { _font = AssetDatabase.LoadAssetAtPath(FontPath) ?? throw new InvalidOperationException("Demo2 UI Toolkit font is missing."); _tutorialFont = AssetDatabase.LoadAssetAtPath(TutorialFontPath) ?? throw new InvalidOperationException("Demo2 tutorial font is missing."); _floor = AssetDatabase.LoadAssetAtPath(Art + "/factory-floor.png"); _itemTexture = AssetDatabase.LoadAssetAtPath(Art + "/material-crate.png"); _machineSprites = MachineNames.Select(name => AssetDatabase.LoadAssetAtPath($"{Art}/machine-{name}.png")).ToArray()!; if (_floor == null || _itemTexture == null || _machineSprites.Any(sprite => sprite == null)) throw new InvalidOperationException("Demo2 art import is incomplete."); } private static void EnsureDirectories() { foreach (var path in new[] { Root, Art, Ui, Root + "/Prefabs", ResourceRoot }) if (!AssetDatabase.IsValidFolder(path)) { var slash = path.LastIndexOf('/'); AssetDatabase.CreateFolder(path[..slash], path[(slash + 1)..]); } } private static void BuildTutorialAssets() { DeleteAssetIfExists(ResourceRoot + "/Demo2FirstCycleTutorial.asset"); DeleteAssetIfExists(ResourceRoot + "/Demo2TutorialDatabase.asset"); DeleteAssetIfExists(ResourceRoot + "/Demo2TutorialSettings.asset"); var tutorial = ScriptableObject.CreateInstance(); tutorial.tutorialId = "demo2.first-cycle"; tutorial.steps = new List { TutorialStep("demo2.miner", "demo2.miner-selected", "选择采集器,准备建立新的铁矿入口。", ShrinkTutorialDialogAnchor.Right), TutorialStep("demo2.grid", "demo2.miner-placed", "在空网格放置采集器。", ShrinkTutorialDialogAnchor.Top), TutorialStep("demo2.conveyor", "demo2.conveyor-selected", "选择传送带,准备连接生产线。", ShrinkTutorialDialogAnchor.Right), TutorialStep("demo2.grid", "demo2.conveyor-placed", "在网格放置传送带,把产物送向加工区。", ShrinkTutorialDialogAnchor.Top), TutorialStep("demo2.smelter", "demo2.smelter-selected", "选择熔炼机,准备把矿石加工为铁锭。", ShrinkTutorialDialogAnchor.Right), TutorialStep("demo2.recipe", "demo2.recipe-selected", "确认当前机器配方。配方决定输入、产出与加工周期。", ShrinkTutorialDialogAnchor.Top), TutorialStep("demo2.turret", "demo2.turret-selected", "选择自动炮塔,准备连接防线。", ShrinkTutorialDialogAnchor.Right), TutorialStep("demo2.grid", "demo2.turret-placed", "在网格放置炮塔。生产的弹药会在结算阶段自动消耗。", ShrinkTutorialDialogAnchor.Top), TutorialStep("demo2.ready", "demo2.production-started", "准备后进入自动生产。周期火力等于载荷乘以倍率。", ShrinkTutorialDialogAnchor.Bottom), TutorialStep("demo2.reward", "demo2.rule-chosen", "防守后选择第一张规则牌。规则槽严格从左到右结算。", ShrinkTutorialDialogAnchor.Bottom) }; AssetDatabase.CreateAsset(tutorial, ResourceRoot + "/Demo2FirstCycleTutorial.asset"); var database = ScriptableObject.CreateInstance(); var serialized = new SerializedObject(database); var tutorials = serialized.FindProperty("tutorials"); tutorials.arraySize = 1; tutorials.GetArrayElementAtIndex(0).objectReferenceValue = tutorial; serialized.ApplyModifiedPropertiesWithoutUndo(); AssetDatabase.CreateAsset(database, ResourceRoot + "/Demo2TutorialDatabase.asset"); var settings = ScriptableObject.CreateInstance(); settings.name = "Demo2TutorialSettings"; settings.database = database; settings.playerPrefsStorageKey = "Demo2.RuleFactory.Tutorial"; settings.showSkipButton = true; settings.canvasSortingOrder = 5000; AssetDatabase.CreateAsset(settings, ResourceRoot + "/Demo2TutorialSettings.asset"); } private static ShrinkTutorialStep TutorialStep(string anchor, string signal, string text, ShrinkTutorialDialogAnchor dialogAnchor) => new() { targetMode = ShrinkTutorialTargetMode.AnchorId, anchorId = anchor, completeCondition = ShrinkTutorialCompleteCondition.CustomEvent, customEventName = signal, fallbackText = text, dialogAnchor = dialogAnchor, maskShape = ShrinkTutorialMaskShape.Rect, maskPadding = 10, waitForTarget = true, waitTimeout = 300 }; private static void AddSceneToBuildSettings() { var scenes = EditorBuildSettings.scenes.ToList(); if (scenes.All(value => value.path != ScenePath)) scenes.Add(new EditorBuildSettingsScene(ScenePath, true)); else for (var i = 0; i < scenes.Count; i++) if (scenes[i].path == ScenePath) scenes[i] = new EditorBuildSettingsScene(ScenePath, true); EditorBuildSettings.scenes = scenes.ToArray(); } private static void SetObject(UnityEngine.Object target, string propertyName, UnityEngine.Object? value) { var serialized = new SerializedObject(target); var property = serialized.FindProperty(propertyName) ?? throw new InvalidOperationException($"Missing serialized property {target.GetType().Name}.{propertyName}"); property.objectReferenceValue = value; serialized.ApplyModifiedPropertiesWithoutUndo(); } private static void SetObjectArray(UnityEngine.Object target, string propertyName, IReadOnlyList values) { var serialized = new SerializedObject(target); var property = serialized.FindProperty(propertyName) ?? throw new InvalidOperationException($"Missing serialized property {target.GetType().Name}.{propertyName}"); property.arraySize = values.Count; for (var i = 0; i < values.Count; i++) property.GetArrayElementAtIndex(i).objectReferenceValue = values[i]; serialized.ApplyModifiedPropertiesWithoutUndo(); } private static void DeleteAssetIfExists(string path) { if (AssetDatabase.LoadAssetAtPath(path) != null) AssetDatabase.DeleteAsset(path); } private static Color Hex(string hex) { if (!ColorUtility.TryParseHtmlString("#" + hex, out var color)) throw new ArgumentException("Invalid color: " + hex); return color; } } }