Files
Workspace/Assets/Demos/Demo2/Editor/Demo2SceneBuilder.cs
T
2026-08-18 02:03:34 +08:00

324 lines
18 KiB
C#

#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<Sprite>();
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<GameObject>(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<SceneAsset>(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>();
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<SpriteRenderer>();
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<Demo2MachineProxyPool>();
proxyPool.SetSprites(_machineSprites);
var itemObject = new GameObject("ItemBatchRenderer", typeof(Demo2ItemBatchRenderer));
itemObject.transform.SetParent(factoryWorld.transform, false);
var itemRenderer = itemObject.GetComponent<Demo2ItemBatchRenderer>();
SetObject(itemRenderer, "itemTexture", _itemTexture);
var uiObject = new GameObject("UI", typeof(UIDocument));
uiObject.transform.SetParent(root.transform, false);
var document = uiObject.GetComponent<UIDocument>();
document.panelSettings = panelSettings;
document.visualTreeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath) ?? throw new InvalidOperationException("Demo2 UXML is missing.");
document.sortingOrder = 0;
var tutorialAnchorRoot = CreateTutorialAnchors(root.transform);
var controller = root.GetComponent<Demo2DemoController>();
SetObject(controller, "worldCamera", camera);
SetObject(controller, "proxyPool", proxyPool);
SetObject(controller, "itemBatchRenderer", itemRenderer);
SetObject(controller, "uiDocument", document);
SetObject(controller, "uiStyleSheet", AssetDatabase.LoadAssetAtPath<StyleSheet>(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<RectTransform>();
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<RectTransform>();
child.SetParent(root, false);
child.anchorMin = Vector2.zero;
child.anchorMax = Vector2.zero;
child.pivot = Vector2.zero;
child.sizeDelta = new Vector2(64, 64);
child.GetComponent<ShrinkTutorialAnchor>().SetAnchorId(id);
child.gameObject.SetActive(false);
}
return root;
}
private static PanelSettings BuildPanelSettings()
{
DeleteAssetIfExists(PanelSettingsPath);
var settings = ScriptableObject.CreateInstance<PanelSettings>();
settings.name = "Demo2PanelSettings";
settings.themeStyleSheet = AssetDatabase.LoadAssetAtPath<ThemeStyleSheet>(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<Font>(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<TMP_FontAsset>(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<Font>(FontPath) ?? throw new InvalidOperationException("Demo2 UI Toolkit font is missing.");
_tutorialFont = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(TutorialFontPath) ?? throw new InvalidOperationException("Demo2 tutorial font is missing.");
_floor = AssetDatabase.LoadAssetAtPath<Sprite>(Art + "/factory-floor.png");
_itemTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(Art + "/material-crate.png");
_machineSprites = MachineNames.Select(name => AssetDatabase.LoadAssetAtPath<Sprite>($"{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<ShrinkTutorialData>();
tutorial.tutorialId = "demo2.first-cycle";
tutorial.steps = new List<ShrinkTutorialStep>
{
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<ShrinkTutorialDatabase>();
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<ShrinkTutorialSettings>();
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<UnityEngine.Object> 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<UnityEngine.Object>(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;
}
}
}