chore: 编辑器分类清理
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
namespace AibisDream.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 项目自研编辑器菜单路径常量。新工具统一挂 <see cref="Root"/> 下扁平条目。
|
||||
/// Create 路径见运行时 <see cref="AibisAssetMenus"/>。
|
||||
/// </summary>
|
||||
public static class AibisEditorMenus
|
||||
{
|
||||
public const string Root = AibisAssetMenus.Root;
|
||||
|
||||
public const string AnimationClipGenerator = Root + "/动画片段生成器";
|
||||
public const string JsonEditor = Root + "/JSON 编辑器";
|
||||
public const string NameCollector = Root + "/名称收集器";
|
||||
public const string ChapterGraphEditor = Root + "/章节 Graph 编辑器";
|
||||
public const string FrameAnimationGraphEditor = Root + "/帧动画 Graph 编辑器";
|
||||
public const string FrameClipEditor = Root + "/帧动画 Clip 编辑器";
|
||||
public const string YarnLocalizationValidator = Root + "/Yarn 本地化校验";
|
||||
|
||||
public const string GameObjectRoot = "GameObject/" + Root;
|
||||
public const string BlockPuzzleShape = GameObjectRoot + "/方块拼图 Shape";
|
||||
|
||||
public static class Create
|
||||
{
|
||||
public const string NarrativeTalkScene = AibisAssetMenus.NarrativeTalkScene;
|
||||
public const string FrameAnimationGraph = AibisAssetMenus.FrameAnimationGraph;
|
||||
public const string FrameClip = AibisAssetMenus.FrameClip;
|
||||
public const string BubbleStyle = AibisAssetMenus.BubbleStyle;
|
||||
public const string FixCheckRule = AibisAssetMenus.FixCheckRule;
|
||||
public const string FixCheckData = AibisAssetMenus.FixCheckData;
|
||||
public const string FixWhackMoleData = AibisAssetMenus.FixWhackMoleData;
|
||||
public const string HuoShanEmotionWave = AibisAssetMenus.HuoShanEmotionWave;
|
||||
public const string HuoShanWaveform = AibisAssetMenus.HuoShanWaveform;
|
||||
public const string BlockPuzzleValidator = AibisAssetMenus.BlockPuzzleValidator;
|
||||
public const string MemoryPunchTapeCatalog = AibisAssetMenus.MemoryPunchTapeCatalog;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AibisDream;
|
||||
using AibisDream.Editor;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
@@ -22,7 +23,7 @@ namespace AibisDream.SystemEditor
|
||||
private DropdownField _folderDropdown;
|
||||
private readonly Dictionary<string, string> _displayToPath = new();
|
||||
|
||||
[MenuItem("Window/Chapter Graph Editor")]
|
||||
[MenuItem(AibisEditorMenus.ChapterGraphEditor)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<ChapterGraphEditorWindow>("Chapter Graph Editor");
|
||||
|
||||
@@ -1,609 +0,0 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AibisDream.UI;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace AibisDream.DeveloperMode.Editor
|
||||
{
|
||||
public static class DeveloperModePanelBuilder
|
||||
{
|
||||
private const string PrefabPath = "Assets/GameContent/Feature_MainUI/Prefabs/DeveloperModePanel.prefab";
|
||||
private const string ScenePath = "Assets/Scenes/Persistence.unity";
|
||||
private const string FontAssetPath = "Assets/Font/Assets/WenQuanYi Bitmap Song 14px SDF.asset";
|
||||
private static readonly Color Background = new(0.025f, 0.045f, 0.065f, 0.97f);
|
||||
private static readonly Color Surface = new(0.055f, 0.085f, 0.11f, 0.96f);
|
||||
private static readonly Color Accent = new(0.16f, 0.75f, 0.88f, 1f);
|
||||
private static readonly Color TextColor = new(0.88f, 0.96f, 1f, 1f);
|
||||
|
||||
[MenuItem("Tools/AIBIS/Developer Mode/Rebuild Runtime Panel")]
|
||||
public static void BuildFromMenu()
|
||||
{
|
||||
Build();
|
||||
Selection.activeObject = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
|
||||
}
|
||||
|
||||
public static void BuildFromCommandLine()
|
||||
{
|
||||
try
|
||||
{
|
||||
Build();
|
||||
Debug.Log("[DeveloperModePanelBuilder] Build completed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogException(ex);
|
||||
EditorApplication.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Build()
|
||||
{
|
||||
var root = BuildPrefabContents();
|
||||
root.SetActive(false);
|
||||
PrefabUtility.SaveAsPrefabAsset(root, PrefabPath);
|
||||
UnityEngine.Object.DestroyImmediate(root);
|
||||
AssetDatabase.SaveAssets();
|
||||
ReplaceSceneInstance();
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
private static GameObject BuildPrefabContents()
|
||||
{
|
||||
var root = CreateUiObject("Developer Mode Panel", typeof(CanvasRenderer), typeof(Image), typeof(DeveloperModePanel));
|
||||
var rootRect = root.GetComponent<RectTransform>();
|
||||
rootRect.anchorMin = new Vector2(1f, 0.5f);
|
||||
rootRect.anchorMax = new Vector2(1f, 0.5f);
|
||||
rootRect.pivot = new Vector2(1f, 0.5f);
|
||||
rootRect.anchoredPosition = new Vector2(-16f, 0f);
|
||||
rootRect.sizeDelta = new Vector2(840f, 760f);
|
||||
root.GetComponent<Image>().color = Background;
|
||||
|
||||
var panel = root.GetComponent<DeveloperModePanel>();
|
||||
var header = CreatePanel("Header", root.transform, Surface);
|
||||
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 1f), new Vector2(1f, 1f),
|
||||
new Vector2(0f, -46f), Vector2.zero);
|
||||
var title = CreateText("Title", header.transform, "AIBIS DREAM / DEVELOPMENT MODE", 22f, FontStyles.Bold);
|
||||
SetAnchors(title.rectTransform, new Vector2(0f, 0f), new Vector2(1f, 1f),
|
||||
new Vector2(18f, 0f), new Vector2(-64f, 0f));
|
||||
title.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
var close = CreateButton("Close", header.transform, "×", 44f, 36f);
|
||||
var closeRect = close.GetComponent<RectTransform>();
|
||||
closeRect.anchorMin = new Vector2(1f, 0.5f);
|
||||
closeRect.anchorMax = new Vector2(1f, 0.5f);
|
||||
closeRect.pivot = new Vector2(1f, 0.5f);
|
||||
closeRect.anchoredPosition = new Vector2(-5f, 0f);
|
||||
closeRect.sizeDelta = new Vector2(44f, 36f);
|
||||
|
||||
var tabs = CreateHorizontal("Tabs", root.transform, 4f, new RectOffset(4, 4, 3, 3));
|
||||
SetAnchors(tabs.GetComponent<RectTransform>(), new Vector2(0f, 1f), new Vector2(1f, 1f),
|
||||
new Vector2(0f, -88f), new Vector2(0f, -46f));
|
||||
var tabNames = new[] { "概览", "日志", "Yarn 变量", "存档槽位", "显示与控制" };
|
||||
var tabButtons = tabNames.Select(name => CreateButton($"Tab {name}", tabs.transform, name, 0f, 34f)).ToArray();
|
||||
foreach (var button in tabButtons)
|
||||
{
|
||||
var layout = button.GetComponent<LayoutElement>();
|
||||
layout.flexibleWidth = 1f;
|
||||
layout.preferredWidth = 0f;
|
||||
}
|
||||
|
||||
var pageRoot = CreateUiObject("Pages", typeof(RectTransform));
|
||||
pageRoot.transform.SetParent(root.transform, false);
|
||||
SetAnchors(pageRoot.GetComponent<RectTransform>(), Vector2.zero, Vector2.one,
|
||||
new Vector2(8f, 8f), new Vector2(-8f, -94f));
|
||||
|
||||
var overview = BuildOverviewPage(pageRoot.transform, out var overviewRefs);
|
||||
var logs = BuildLogPage(pageRoot.transform, out var logRefs);
|
||||
var variables = BuildVariablePage(pageRoot.transform, out var variableRefs);
|
||||
var saves = BuildSavePage(pageRoot.transform, out var saveRefs);
|
||||
var display = BuildDisplayPage(pageRoot.transform, out var displayRefs);
|
||||
var pages = new[] { overview, logs, variables, saves, display };
|
||||
foreach (var page in pages)
|
||||
{
|
||||
Stretch(page.GetComponent<RectTransform>());
|
||||
page.SetActive(false);
|
||||
}
|
||||
|
||||
var serialized = new SerializedObject(panel);
|
||||
Set(serialized, "closeButton", close.GetComponent<Button>());
|
||||
SetArray(serialized, "tabButtons", tabButtons.Select(item => (UnityEngine.Object)item.GetComponent<Button>()).ToArray());
|
||||
SetArray(serialized, "tabPages", pages.Cast<UnityEngine.Object>().ToArray());
|
||||
overviewRefs.Apply(serialized);
|
||||
logRefs.Apply(serialized);
|
||||
variableRefs.Apply(serialized);
|
||||
saveRefs.Apply(serialized);
|
||||
displayRefs.Apply(serialized);
|
||||
serialized.ApplyModifiedPropertiesWithoutUndo();
|
||||
return root;
|
||||
}
|
||||
|
||||
private static GameObject BuildOverviewPage(Transform parent, out ReferenceSet refs)
|
||||
{
|
||||
refs = new ReferenceSet();
|
||||
var page = CreateVertical("Overview Page", parent, 8f, new RectOffset(12, 12, 12, 12));
|
||||
var header = CreateText("Section Title", page.transform, "运行状态", 20f, FontStyles.Bold);
|
||||
AddLayout(header.gameObject, 32f);
|
||||
var overview = CreateTextArea("Overview Text", page.transform, 520f, out _);
|
||||
overview.fontSize = 17f;
|
||||
overview.alignment = TextAlignmentOptions.TopLeft;
|
||||
overview.enableWordWrapping = false;
|
||||
var copy = CreateButton("Copy Diagnostics", page.transform, "复制诊断摘要", 180f, 38f);
|
||||
var status = CreateText("Status", page.transform, string.Empty, 15f);
|
||||
AddLayout(status.gameObject, 28f);
|
||||
status.color = new Color(0.45f, 1f, 0.65f);
|
||||
refs.Add("overviewText", overview);
|
||||
refs.Add("overviewStatusText", status);
|
||||
refs.Add("copyDiagnosticsButton", copy.GetComponent<Button>());
|
||||
return page;
|
||||
}
|
||||
|
||||
private static GameObject BuildLogPage(Transform parent, out ReferenceSet refs)
|
||||
{
|
||||
refs = new ReferenceSet();
|
||||
var page = CreateVertical("Logs Page", parent, 6f, new RectOffset(8, 8, 8, 8));
|
||||
var toolbar1 = CreateHorizontal("Filters", page.transform, 6f, new RectOffset(0, 0, 0, 0));
|
||||
AddLayout(toolbar1, 38f);
|
||||
var search = CreateInput("Log Search", toolbar1.transform, "搜索 message/context/stack...", 290f);
|
||||
var level = CreateButton("Log Level", toolbar1.transform, "最低等级:全部", 150f, 34f);
|
||||
var category = CreateButton("Log Category", toolbar1.transform, "类别:全部", 130f, 34f);
|
||||
var pause = CreateToggle("Pause Log", toolbar1.transform, "暂停滚动", 110f, true);
|
||||
pause.isOn = false;
|
||||
|
||||
var toolbar2 = CreateHorizontal("Actions", page.transform, 6f, new RectOffset(0, 0, 0, 0));
|
||||
AddLayout(toolbar2, 36f);
|
||||
var clear = CreateButton("Clear", toolbar2.transform, "清空内存", 100f, 32f);
|
||||
var copyFiltered = CreateButton("Copy Filtered", toolbar2.transform, "复制过滤结果", 130f, 32f);
|
||||
var copySelected = CreateButton("Copy Selected", toolbar2.transform, "复制选中/堆栈", 150f, 32f);
|
||||
var count = CreateText("Count", toolbar2.transform, "缓冲 0", 14f);
|
||||
AddFlexible(count.gameObject);
|
||||
count.alignment = TextAlignmentOptions.MidlineRight;
|
||||
|
||||
var scroll = CreateList("Log List", page.transform, 390f, out var content, out var template);
|
||||
var details = CreateTextArea("Log Details", page.transform, 150f, out _);
|
||||
details.text = "未选择日志。";
|
||||
details.fontSize = 14f;
|
||||
|
||||
refs.Add("logSearchInput", search);
|
||||
refs.Add("logLevelButton", level.GetComponent<Button>());
|
||||
refs.Add("logLevelLabel", level.GetComponentInChildren<TMP_Text>());
|
||||
refs.Add("logCategoryButton", category.GetComponent<Button>());
|
||||
refs.Add("logCategoryLabel", category.GetComponentInChildren<TMP_Text>());
|
||||
refs.Add("pauseLogToggle", pause);
|
||||
refs.Add("clearLogsButton", clear.GetComponent<Button>());
|
||||
refs.Add("copyFilteredLogsButton", copyFiltered.GetComponent<Button>());
|
||||
refs.Add("copySelectedLogButton", copySelected.GetComponent<Button>());
|
||||
refs.Add("logCountText", count);
|
||||
refs.Add("logDetailsText", details);
|
||||
refs.Add("logScrollRect", scroll);
|
||||
refs.Add("logContent", content);
|
||||
refs.Add("logRowTemplate", template);
|
||||
return page;
|
||||
}
|
||||
|
||||
private static GameObject BuildVariablePage(Transform parent, out ReferenceSet refs)
|
||||
{
|
||||
refs = new ReferenceSet();
|
||||
var page = CreateVertical("Variables Page", parent, 7f, new RectOffset(8, 8, 8, 8));
|
||||
var toolbar = CreateHorizontal("Filters", page.transform, 6f, new RectOffset(0, 0, 0, 0));
|
||||
AddLayout(toolbar, 40f);
|
||||
var search = CreateInput("Variable Search", toolbar.transform, "搜索变量名或值...", 320f);
|
||||
var scope = CreateButton("Variable Scope", toolbar.transform, "范围:全部", 130f, 34f);
|
||||
var type = CreateButton("Variable Type", toolbar.transform, "类型:全部", 140f, 34f);
|
||||
var count = CreateText("Count", toolbar.transform, "0 variables", 14f);
|
||||
AddFlexible(count.gameObject);
|
||||
count.alignment = TextAlignmentOptions.MidlineRight;
|
||||
CreateList("Variable List", page.transform, 620f, out var content, out var template);
|
||||
refs.Add("variableSearchInput", search);
|
||||
refs.Add("variableScopeButton", scope.GetComponent<Button>());
|
||||
refs.Add("variableScopeLabel", scope.GetComponentInChildren<TMP_Text>());
|
||||
refs.Add("variableTypeButton", type.GetComponent<Button>());
|
||||
refs.Add("variableTypeLabel", type.GetComponentInChildren<TMP_Text>());
|
||||
refs.Add("variableCountText", count);
|
||||
refs.Add("variableContent", content);
|
||||
refs.Add("variableRowTemplate", template);
|
||||
return page;
|
||||
}
|
||||
|
||||
private static GameObject BuildSavePage(Transform parent, out ReferenceSet refs)
|
||||
{
|
||||
refs = new ReferenceSet();
|
||||
var page = CreateVertical("Saves Page", parent, 6f, new RectOffset(8, 8, 8, 8));
|
||||
var toolbar = CreateHorizontal("Toolbar", page.transform, 6f, new RectOffset(0, 0, 0, 0));
|
||||
AddLayout(toolbar, 38f);
|
||||
var reload = CreateButton("Reload Saves", toolbar.transform, "重新扫描", 110f, 34f);
|
||||
var status = CreateText("Save Status", toolbar.transform, "Ready", 14f);
|
||||
AddFlexible(status.gameObject);
|
||||
status.alignment = TextAlignmentOptions.MidlineRight;
|
||||
|
||||
var slotColumn = CreateVertical("Official Slots", page.transform, 4f, new RectOffset(0, 0, 0, 0));
|
||||
var slotLayout = slotColumn.AddComponent<LayoutElement>();
|
||||
slotLayout.flexibleHeight = 1f;
|
||||
var slotTitle = CreateText("Title", slotColumn.transform, "正式槽位(点击读档)", 16f, FontStyles.Bold);
|
||||
AddLayout(slotTitle.gameObject, 28f);
|
||||
CreateList("Slot List", slotColumn.transform, 500f, out var slotContent, out var slotTemplate);
|
||||
|
||||
var paths1 = CreateHorizontal("Paths 1", page.transform, 5f, new RectOffset(0, 0, 0, 0));
|
||||
AddLayout(paths1, 34f);
|
||||
var openSave = CreateButton("Open Saves", paths1.transform, "打开存档目录", 130f, 30f);
|
||||
var copySave = CreateButton("Copy Saves", paths1.transform, "复制存档路径", 130f, 30f);
|
||||
var openLog = CreateButton("Open Logs", paths1.transform, "打开日志目录", 130f, 30f);
|
||||
var copyLog = CreateButton("Copy Logs", paths1.transform, "复制日志路径", 130f, 30f);
|
||||
var openCurrent = CreateButton("Open Current Log", paths1.transform, "定位当前日志", 130f, 30f);
|
||||
var copyCurrent = CreateButton("Copy Current Log", paths1.transform, "复制当前日志", 130f, 30f);
|
||||
|
||||
refs.Add("reloadSavesButton", reload.GetComponent<Button>());
|
||||
refs.Add("saveStatusText", status);
|
||||
refs.Add("slotContent", slotContent);
|
||||
refs.Add("slotRowTemplate", slotTemplate);
|
||||
refs.Add("openSaveFolderButton", openSave.GetComponent<Button>());
|
||||
refs.Add("copySaveFolderButton", copySave.GetComponent<Button>());
|
||||
refs.Add("openLogFolderButton", openLog.GetComponent<Button>());
|
||||
refs.Add("copyLogFolderButton", copyLog.GetComponent<Button>());
|
||||
refs.Add("openCurrentLogButton", openCurrent.GetComponent<Button>());
|
||||
refs.Add("copyCurrentLogButton", copyCurrent.GetComponent<Button>());
|
||||
return page;
|
||||
}
|
||||
|
||||
private static GameObject BuildDisplayPage(Transform parent, out ReferenceSet refs)
|
||||
{
|
||||
refs = new ReferenceSet();
|
||||
var page = CreateVertical("Display Page", parent, 10f, new RectOffset(18, 18, 18, 18));
|
||||
var title = CreateText("Title", page.transform, "录制显示", 20f, FontStyles.Bold);
|
||||
AddLayout(title.gameObject, 34f);
|
||||
var cursor = CreateToggle("Show Cursor", page.transform, "显示游戏光标(面板打开时始终可见)", 600f, true);
|
||||
var version = CreateToggle("Show Version", page.transform, "显示版本号", 600f, true);
|
||||
var topBar = CreateToggle("Show Top Bar", page.transform, "显示 Top Bar", 600f, true);
|
||||
var dialog = CreateToggle("Show Dialog", page.transform, "显示对话视觉(不停止 Yarn)", 600f, true);
|
||||
var divider = CreateText("Control Title", page.transform, "会话与时间控制", 20f, FontStyles.Bold);
|
||||
AddLayout(divider.gameObject, 44f);
|
||||
var sessionButtons = CreateHorizontal("Session Buttons", page.transform, 8f, new RectOffset(0, 0, 0, 0));
|
||||
AddLayout(sessionButtons, 42f);
|
||||
var pause = CreateButton("Pause", sessionButtons.transform, "暂停", 120f, 36f);
|
||||
var resume = CreateButton("Resume", sessionButtons.transform, "继续", 120f, 36f);
|
||||
var speedButtons = CreateHorizontal("Speed Buttons", page.transform, 8f, new RectOffset(0, 0, 0, 0));
|
||||
AddLayout(speedButtons, 42f);
|
||||
var half = CreateButton("Half Speed", speedButtons.transform, "0.5×", 120f, 36f);
|
||||
var normal = CreateButton("Normal Speed", speedButtons.transform, "1×", 120f, 36f);
|
||||
var twice = CreateButton("Double Speed", speedButtons.transform, "2×", 120f, 36f);
|
||||
var status = CreateText("Control Status", page.transform, "游戏保持运行;所有控制仍受 GameManager 状态约束。", 16f);
|
||||
AddLayout(status.gameObject, 60f);
|
||||
|
||||
refs.Add("showCursorToggle", cursor);
|
||||
refs.Add("showVersionToggle", version);
|
||||
refs.Add("showTopBarToggle", topBar);
|
||||
refs.Add("showDialogToggle", dialog);
|
||||
refs.Add("pauseGameButton", pause.GetComponent<Button>());
|
||||
refs.Add("resumeGameButton", resume.GetComponent<Button>());
|
||||
refs.Add("halfSpeedButton", half.GetComponent<Button>());
|
||||
refs.Add("normalSpeedButton", normal.GetComponent<Button>());
|
||||
refs.Add("doubleSpeedButton", twice.GetComponent<Button>());
|
||||
refs.Add("controlStatusText", status);
|
||||
return page;
|
||||
}
|
||||
|
||||
private static void ReplaceSceneInstance()
|
||||
{
|
||||
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
|
||||
var infoPanel = scene.GetRootGameObjects()
|
||||
.SelectMany(root => root.GetComponentsInChildren<Transform>(true))
|
||||
.FirstOrDefault(item => item.name == "Info Panel");
|
||||
if (infoPanel == null) throw new InvalidOperationException("Persistence/Info Panel not found.");
|
||||
|
||||
var existingPanels = scene.GetRootGameObjects()
|
||||
.SelectMany(root => root.GetComponentsInChildren<DeveloperModePanel>(true))
|
||||
.ToArray();
|
||||
var existingInInfoPanel = existingPanels.FirstOrDefault(item => item.transform.parent == infoPanel);
|
||||
var siblingIndex = existingInInfoPanel != null
|
||||
? existingInInfoPanel.transform.GetSiblingIndex()
|
||||
: infoPanel.childCount;
|
||||
foreach (var existing in existingPanels)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(existing.gameObject);
|
||||
}
|
||||
|
||||
var legacyPanels = scene.GetRootGameObjects()
|
||||
.SelectMany(root => root.GetComponentsInChildren<Transform>(true))
|
||||
.Where(item => item.name == "Recording Tool Panel")
|
||||
.Select(item => item.gameObject)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
foreach (var legacyPanel in legacyPanels)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(legacyPanel);
|
||||
}
|
||||
|
||||
var variableBar = infoPanel.GetComponentsInChildren<Transform>(true)
|
||||
.FirstOrDefault(item => item.name == "Varible Bar" || item.name == "Variable Bar");
|
||||
if (variableBar != null) UnityEngine.Object.DestroyImmediate(variableBar.gameObject);
|
||||
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
|
||||
if (prefab == null) throw new InvalidOperationException($"Prefab not found: {PrefabPath}");
|
||||
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab, scene);
|
||||
instance.transform.SetParent(infoPanel, false);
|
||||
instance.transform.SetSiblingIndex(Mathf.Clamp(siblingIndex, 0, infoPanel.childCount - 1));
|
||||
instance.SetActive(false);
|
||||
EditorSceneManager.MarkSceneDirty(scene);
|
||||
EditorSceneManager.SaveScene(scene);
|
||||
}
|
||||
|
||||
private static GameObject CreateUiObject(string name, params Type[] components)
|
||||
{
|
||||
var types = components.Contains(typeof(RectTransform))
|
||||
? components
|
||||
: new[] { typeof(RectTransform) }.Concat(components).ToArray();
|
||||
var go = new GameObject(name, types);
|
||||
go.layer = LayerMask.NameToLayer("UI");
|
||||
return go;
|
||||
}
|
||||
|
||||
private static GameObject CreatePanel(string name, Transform parent, Color color)
|
||||
{
|
||||
var go = CreateUiObject(name, typeof(CanvasRenderer), typeof(Image));
|
||||
go.transform.SetParent(parent, false);
|
||||
go.GetComponent<Image>().color = color;
|
||||
return go;
|
||||
}
|
||||
|
||||
private static GameObject CreateVertical(string name, Transform parent, float spacing, RectOffset padding)
|
||||
{
|
||||
var go = CreateUiObject(name, typeof(VerticalLayoutGroup));
|
||||
go.transform.SetParent(parent, false);
|
||||
var layout = go.GetComponent<VerticalLayoutGroup>();
|
||||
layout.padding = padding;
|
||||
layout.spacing = spacing;
|
||||
layout.childAlignment = TextAnchor.UpperLeft;
|
||||
layout.childControlWidth = true;
|
||||
layout.childForceExpandWidth = true;
|
||||
layout.childControlHeight = true;
|
||||
layout.childForceExpandHeight = false;
|
||||
return go;
|
||||
}
|
||||
|
||||
private static GameObject CreateHorizontal(string name, Transform parent, float spacing, RectOffset padding)
|
||||
{
|
||||
var go = CreateUiObject(name, typeof(HorizontalLayoutGroup));
|
||||
go.transform.SetParent(parent, false);
|
||||
var layout = go.GetComponent<HorizontalLayoutGroup>();
|
||||
layout.padding = padding;
|
||||
layout.spacing = spacing;
|
||||
layout.childAlignment = TextAnchor.MiddleLeft;
|
||||
layout.childControlWidth = true;
|
||||
layout.childControlHeight = true;
|
||||
layout.childForceExpandWidth = false;
|
||||
layout.childForceExpandHeight = true;
|
||||
return go;
|
||||
}
|
||||
|
||||
private static TMP_Text CreateText(string name, Transform parent, string value, float size,
|
||||
FontStyles style = FontStyles.Normal)
|
||||
{
|
||||
var go = CreateUiObject(name, typeof(CanvasRenderer), typeof(TextMeshProUGUI));
|
||||
go.transform.SetParent(parent, false);
|
||||
var text = go.GetComponent<TextMeshProUGUI>();
|
||||
text.font = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(FontAssetPath);
|
||||
text.text = value;
|
||||
text.fontSize = size;
|
||||
text.fontStyle = style;
|
||||
text.color = TextColor;
|
||||
text.enableWordWrapping = true;
|
||||
text.overflowMode = TextOverflowModes.Ellipsis;
|
||||
text.raycastTarget = false;
|
||||
return text;
|
||||
}
|
||||
|
||||
private static TMP_Text CreateTextArea(string name, Transform parent, float height, out GameObject area)
|
||||
{
|
||||
area = CreatePanel(name, parent, Surface);
|
||||
AddLayout(area, height);
|
||||
area.AddComponent<RectMask2D>();
|
||||
var text = CreateText("Text", area.transform, string.Empty, 15f);
|
||||
SetAnchors(text.rectTransform, Vector2.zero, Vector2.one, new Vector2(9f, 7f), new Vector2(-9f, -7f));
|
||||
text.alignment = TextAlignmentOptions.TopLeft;
|
||||
text.overflowMode = TextOverflowModes.Truncate;
|
||||
return text;
|
||||
}
|
||||
|
||||
private static GameObject CreateButton(string name, Transform parent, string label, float width, float height)
|
||||
{
|
||||
var go = CreateUiObject(name, typeof(CanvasRenderer), typeof(Image), typeof(Button), typeof(LayoutElement));
|
||||
go.transform.SetParent(parent, false);
|
||||
var image = go.GetComponent<Image>();
|
||||
image.color = new Color(0.09f, 0.18f, 0.23f, 1f);
|
||||
var button = go.GetComponent<Button>();
|
||||
button.targetGraphic = image;
|
||||
var colors = button.colors;
|
||||
colors.normalColor = Color.white;
|
||||
colors.highlightedColor = new Color(0.75f, 1f, 1f, 1f);
|
||||
colors.pressedColor = new Color(0.45f, 0.85f, 0.95f, 1f);
|
||||
colors.disabledColor = new Color(0.35f, 0.4f, 0.42f, 0.65f);
|
||||
button.colors = colors;
|
||||
var layout = go.GetComponent<LayoutElement>();
|
||||
if (width > 0f) layout.preferredWidth = width;
|
||||
layout.preferredHeight = height;
|
||||
var text = CreateText("Label", go.transform, label, 14f, FontStyles.Bold);
|
||||
Stretch(text.rectTransform, new Vector2(5f, 2f), new Vector2(-5f, -2f));
|
||||
text.alignment = TextAlignmentOptions.Center;
|
||||
return go;
|
||||
}
|
||||
|
||||
private static TMP_InputField CreateInput(string name, Transform parent, string placeholder, float width)
|
||||
{
|
||||
var root = CreateUiObject(name, typeof(CanvasRenderer), typeof(Image), typeof(TMP_InputField), typeof(LayoutElement));
|
||||
root.transform.SetParent(parent, false);
|
||||
root.GetComponent<Image>().color = new Color(0.035f, 0.065f, 0.085f, 1f);
|
||||
var layout = root.GetComponent<LayoutElement>();
|
||||
layout.preferredWidth = width;
|
||||
layout.preferredHeight = 34f;
|
||||
var viewport = CreateUiObject("Text Area", typeof(RectMask2D));
|
||||
viewport.transform.SetParent(root.transform, false);
|
||||
Stretch(viewport.GetComponent<RectTransform>(), new Vector2(8f, 3f), new Vector2(-8f, -3f));
|
||||
var placeholderText = CreateText("Placeholder", viewport.transform, placeholder, 14f);
|
||||
Stretch(placeholderText.rectTransform);
|
||||
placeholderText.color = new Color(0.45f, 0.58f, 0.63f, 1f);
|
||||
placeholderText.fontStyle = FontStyles.Italic;
|
||||
placeholderText.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
var inputText = CreateText("Text", viewport.transform, string.Empty, 14f);
|
||||
Stretch(inputText.rectTransform);
|
||||
inputText.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
var input = root.GetComponent<TMP_InputField>();
|
||||
input.textViewport = viewport.GetComponent<RectTransform>();
|
||||
input.textComponent = inputText;
|
||||
input.placeholder = placeholderText;
|
||||
input.lineType = TMP_InputField.LineType.SingleLine;
|
||||
return input;
|
||||
}
|
||||
|
||||
private static Toggle CreateToggle(string name, Transform parent, string label, float width, bool initial)
|
||||
{
|
||||
var root = CreateUiObject(name, typeof(Toggle), typeof(LayoutElement));
|
||||
root.transform.SetParent(parent, false);
|
||||
var layout = root.GetComponent<LayoutElement>();
|
||||
layout.preferredWidth = width;
|
||||
layout.preferredHeight = 34f;
|
||||
var box = CreatePanel("Background", root.transform, new Color(0.08f, 0.15f, 0.19f, 1f));
|
||||
var boxRect = box.GetComponent<RectTransform>();
|
||||
boxRect.anchorMin = boxRect.anchorMax = new Vector2(0f, 0.5f);
|
||||
boxRect.pivot = new Vector2(0f, 0.5f);
|
||||
boxRect.anchoredPosition = Vector2.zero;
|
||||
boxRect.sizeDelta = new Vector2(24f, 24f);
|
||||
var check = CreatePanel("Checkmark", box.transform, Accent);
|
||||
Stretch(check.GetComponent<RectTransform>(), new Vector2(5f, 5f), new Vector2(-5f, -5f));
|
||||
var text = CreateText("Label", root.transform, label, 15f);
|
||||
SetAnchors(text.rectTransform, Vector2.zero, Vector2.one, new Vector2(34f, 0f), Vector2.zero);
|
||||
text.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
var toggle = root.GetComponent<Toggle>();
|
||||
toggle.targetGraphic = box.GetComponent<Image>();
|
||||
toggle.graphic = check.GetComponent<Image>();
|
||||
toggle.isOn = initial;
|
||||
return toggle;
|
||||
}
|
||||
|
||||
private static ScrollRect CreateList(string name, Transform parent, float height, out RectTransform content,
|
||||
out DeveloperModeListRow template)
|
||||
{
|
||||
var root = CreatePanel(name, parent, new Color(0.02f, 0.035f, 0.05f, 1f));
|
||||
var rootLayout = root.AddComponent<LayoutElement>();
|
||||
rootLayout.preferredHeight = height;
|
||||
rootLayout.flexibleHeight = 1f;
|
||||
var scroll = root.AddComponent<ScrollRect>();
|
||||
scroll.horizontal = false;
|
||||
scroll.vertical = true;
|
||||
scroll.movementType = ScrollRect.MovementType.Clamped;
|
||||
scroll.scrollSensitivity = 24f;
|
||||
|
||||
var viewport = CreateUiObject("Viewport", typeof(RectMask2D));
|
||||
viewport.transform.SetParent(root.transform, false);
|
||||
Stretch(viewport.GetComponent<RectTransform>(), new Vector2(3f, 3f), new Vector2(-3f, -3f));
|
||||
var contentObject = CreateUiObject("Content", typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
|
||||
contentObject.transform.SetParent(viewport.transform, false);
|
||||
content = contentObject.GetComponent<RectTransform>();
|
||||
content.anchorMin = new Vector2(0f, 1f);
|
||||
content.anchorMax = new Vector2(1f, 1f);
|
||||
content.pivot = new Vector2(0.5f, 1f);
|
||||
content.anchoredPosition = Vector2.zero;
|
||||
content.sizeDelta = Vector2.zero;
|
||||
var vertical = contentObject.GetComponent<VerticalLayoutGroup>();
|
||||
vertical.spacing = 2f;
|
||||
vertical.childControlWidth = true;
|
||||
vertical.childForceExpandWidth = true;
|
||||
vertical.childControlHeight = true;
|
||||
vertical.childForceExpandHeight = false;
|
||||
var fitter = contentObject.GetComponent<ContentSizeFitter>();
|
||||
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
|
||||
var row = CreateUiObject("Row Template", typeof(CanvasRenderer), typeof(Image), typeof(Button),
|
||||
typeof(LayoutElement), typeof(DeveloperModeListRow));
|
||||
row.transform.SetParent(content, false);
|
||||
var rowImage = row.GetComponent<Image>();
|
||||
rowImage.color = Surface;
|
||||
var rowButton = row.GetComponent<Button>();
|
||||
rowButton.targetGraphic = rowImage;
|
||||
var rowLayout = row.GetComponent<LayoutElement>();
|
||||
rowLayout.preferredHeight = 28f;
|
||||
rowLayout.minHeight = 28f;
|
||||
var rowText = CreateText("Label", row.transform, "Template", 13f);
|
||||
Stretch(rowText.rectTransform, new Vector2(7f, 2f), new Vector2(-7f, -2f));
|
||||
rowText.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
rowText.enableWordWrapping = false;
|
||||
var rowSerialized = new SerializedObject(row.GetComponent<DeveloperModeListRow>());
|
||||
Set(rowSerialized, "button", rowButton);
|
||||
Set(rowSerialized, "label", rowText);
|
||||
Set(rowSerialized, "background", rowImage);
|
||||
rowSerialized.ApplyModifiedPropertiesWithoutUndo();
|
||||
row.SetActive(false);
|
||||
template = row.GetComponent<DeveloperModeListRow>();
|
||||
scroll.viewport = viewport.GetComponent<RectTransform>();
|
||||
scroll.content = content;
|
||||
return scroll;
|
||||
}
|
||||
|
||||
private static void SetAnchors(RectTransform rect, Vector2 min, Vector2 max, Vector2 offsetMin, Vector2 offsetMax)
|
||||
{
|
||||
rect.anchorMin = min;
|
||||
rect.anchorMax = max;
|
||||
rect.offsetMin = offsetMin;
|
||||
rect.offsetMax = offsetMax;
|
||||
}
|
||||
|
||||
private static void Stretch(RectTransform rect) => Stretch(rect, Vector2.zero, Vector2.zero);
|
||||
|
||||
private static void Stretch(RectTransform rect, Vector2 offsetMin, Vector2 offsetMax)
|
||||
{
|
||||
rect.anchorMin = Vector2.zero;
|
||||
rect.anchorMax = Vector2.one;
|
||||
rect.offsetMin = offsetMin;
|
||||
rect.offsetMax = offsetMax;
|
||||
}
|
||||
|
||||
private static void AddLayout(GameObject go, float height)
|
||||
{
|
||||
var layout = go.GetComponent<LayoutElement>() ?? go.AddComponent<LayoutElement>();
|
||||
layout.preferredHeight = height;
|
||||
}
|
||||
|
||||
private static void AddFlexible(GameObject go)
|
||||
{
|
||||
var layout = go.GetComponent<LayoutElement>() ?? go.AddComponent<LayoutElement>();
|
||||
layout.flexibleWidth = 1f;
|
||||
}
|
||||
|
||||
private static void Set(SerializedObject serialized, string name, UnityEngine.Object value)
|
||||
{
|
||||
var property = serialized.FindProperty(name)
|
||||
?? throw new MissingFieldException(serialized.targetObject.GetType().Name, name);
|
||||
property.objectReferenceValue = value;
|
||||
}
|
||||
|
||||
private static void SetArray(SerializedObject serialized, string name, UnityEngine.Object[] values)
|
||||
{
|
||||
var property = serialized.FindProperty(name)
|
||||
?? throw new MissingFieldException(serialized.targetObject.GetType().Name, name);
|
||||
property.arraySize = values.Length;
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
{
|
||||
property.GetArrayElementAtIndex(i).objectReferenceValue = values[i];
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ReferenceSet
|
||||
{
|
||||
private readonly System.Collections.Generic.List<(string Name, UnityEngine.Object Value)> _values = new();
|
||||
|
||||
public void Add(string name, UnityEngine.Object value) => _values.Add((name, value));
|
||||
|
||||
public void Apply(SerializedObject serialized)
|
||||
{
|
||||
foreach (var value in _values)
|
||||
{
|
||||
Set(serialized, value.Name, value.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f0bdf861144e8346b6adfc5e3faaaaf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -3,6 +3,7 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AibisDream.Editor;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Callbacks;
|
||||
using UnityEditor.UIElements;
|
||||
@@ -84,7 +85,7 @@ namespace AibisDream.FrameAnimation.Editor
|
||||
private double lastPreviewTick;
|
||||
private bool updatingPreviewUi;
|
||||
|
||||
[MenuItem("Window/Aibis Dream/Frame Animation Graph Editor")]
|
||||
[MenuItem(AibisEditorMenus.FrameAnimationGraphEditor)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<FrameAnimationGraphEditorWindow>();
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
public static class FrameAnimationImportSampleBuilder
|
||||
{
|
||||
private const string SampleFolder = "Assets/GameContent/Test/FrameAnimation/Import";
|
||||
private const string ReadOnlyTexturePath = SampleFolder + "/中文预切图.png";
|
||||
private const string WritableTexturePath = SampleFolder + "/中文自动切图.png";
|
||||
private const string ObjectJsonPath = SampleFolder + "/中文Object.json";
|
||||
private const string ArrayJsonPath = SampleFolder + "/中文Array.json";
|
||||
private const string GraphPath = SampleFolder + "/ImportSampleGraph.asset";
|
||||
private const string IdleNodeId = "40000000000000000000000000000001";
|
||||
private const string BlinkNodeId = "40000000000000000000000000000002";
|
||||
private const string SharedNodeId = "40000000000000000000000000000003";
|
||||
private const string IdleEdgeId = "40000000000000000000000000000011";
|
||||
private const string BlinkEdgeId = "40000000000000000000000000000012";
|
||||
private const string IdleFlowId = "SampleIdleToSharedFlow";
|
||||
private const string BlinkFlowId = "SampleBlinkToSharedFlow";
|
||||
|
||||
[MenuItem("Tools/Frame Animation/Rebuild Import Sample")]
|
||||
private static void BuildFromMenu()
|
||||
{
|
||||
BuildFromCommandLine();
|
||||
}
|
||||
|
||||
public static void BuildFromCommandLine()
|
||||
{
|
||||
EnsureFolder(SampleFolder);
|
||||
if (AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(GraphPath) != null)
|
||||
{
|
||||
AssetDatabase.DeleteAsset(GraphPath);
|
||||
}
|
||||
|
||||
WriteTexture(ReadOnlyTexturePath);
|
||||
WriteTexture(WritableTexturePath);
|
||||
WriteTextAsset(ObjectJsonPath, CreateObjectJson("中文预切图.png", "待机", "眨眼"));
|
||||
WriteTextAsset(ArrayJsonPath, CreateArrayJson("中文自动切图.png", "转身", "惊讶"));
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
|
||||
var readOnlyTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(ReadOnlyTexturePath);
|
||||
var objectJson = AssetDatabase.LoadAssetAtPath<TextAsset>(ObjectJsonPath);
|
||||
if (!AsepriteJsonParser.TryParse(objectJson.text, "sample-slicing", out var document, out var parseIssue))
|
||||
{
|
||||
throw new InvalidOperationException(parseIssue.Message);
|
||||
}
|
||||
var slicingSource = new FrameAnimationImportSource(
|
||||
"Prepare Read Only Texture", readOnlyTexture, objectJson, new Vector2(0.5f, 0.5f), true);
|
||||
var slicingPreview = new FrameAnimationImportSourcePreview(slicingSource) { Document = document };
|
||||
var slicingPlan = FrameAnimationSpriteUtility.BuildPlan(slicingSource, document, slicingPreview);
|
||||
if (slicingPreview.HasErrors)
|
||||
{
|
||||
throw new InvalidOperationException(string.Join("\n", System.Linq.Enumerable.Select(
|
||||
slicingPreview.Issues, issue => issue.Message)));
|
||||
}
|
||||
FrameAnimationSpriteUtility.ApplyPlan(slicingPlan);
|
||||
|
||||
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
|
||||
graph.Configure(
|
||||
"ImportSampleGraph",
|
||||
"Aseprite 导入与稳定刷新样例",
|
||||
Array.Empty<FrameClip>(),
|
||||
Array.Empty<AnimationNode>(),
|
||||
Array.Empty<AnimationEdge>(),
|
||||
Array.Empty<AnimationFlow>(),
|
||||
string.Empty);
|
||||
graph.AddImportSource(new FrameAnimationImportSource(
|
||||
"Object / 只读切图",
|
||||
AssetDatabase.LoadAssetAtPath<Texture2D>(ReadOnlyTexturePath),
|
||||
AssetDatabase.LoadAssetAtPath<TextAsset>(ObjectJsonPath),
|
||||
new Vector2(0.5f, 0.5f),
|
||||
false));
|
||||
graph.AddImportSource(new FrameAnimationImportSource(
|
||||
"Array / 自动切图",
|
||||
AssetDatabase.LoadAssetAtPath<Texture2D>(WritableTexturePath),
|
||||
AssetDatabase.LoadAssetAtPath<TextAsset>(ArrayJsonPath),
|
||||
new Vector2(0.5f, 0.5f),
|
||||
true));
|
||||
AssetDatabase.CreateAsset(graph, GraphPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
var preview = FrameAnimationImportService.PreviewAll(graph);
|
||||
if (!FrameAnimationImportService.Apply(preview, true, out var error))
|
||||
{
|
||||
throw new InvalidOperationException(error + "\n" + string.Join("\n",
|
||||
System.Linq.Enumerable.SelectMany(preview.Sources,
|
||||
source => System.Linq.Enumerable.Select(source.Issues, issue => issue.Message))));
|
||||
}
|
||||
|
||||
ExtendForWorkspaceSample(graph);
|
||||
EditorUtility.SetDirty(graph);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
Selection.activeObject = graph;
|
||||
Debug.Log($"Frame Animation import sample rebuilt: {GraphPath}");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Frame Animation/Upgrade Phase 4 Sample (Non-Destructive)")]
|
||||
public static void UpgradePhase4Sample()
|
||||
{
|
||||
var graph = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(GraphPath);
|
||||
if (graph == null)
|
||||
{
|
||||
Debug.LogError($"Frame Animation sample Graph not found: {GraphPath}");
|
||||
return;
|
||||
}
|
||||
var idleClip = graph.Clips.FirstOrDefault(clip => clip?.ImportInfo?.SourceTagName == "待机");
|
||||
var blinkClip = graph.Clips.FirstOrDefault(clip => clip?.ImportInfo?.SourceTagName == "眨眼");
|
||||
var sharedClip = AssetDatabase.LoadAssetAtPath<FrameClip>(
|
||||
"Assets/GameContent/Test/FrameAnimation/Idle.asset") ??
|
||||
graph.Clips.FirstOrDefault(clip => clip != null && !clip.IsImported);
|
||||
if (idleClip == null || blinkClip == null || sharedClip == null || !graph.Clips.Contains(sharedClip))
|
||||
{
|
||||
Debug.LogError("Phase 4 sample upgrade requires 待机、眨眼 and a Manual Clip already referenced by the Graph.");
|
||||
return;
|
||||
}
|
||||
|
||||
Undo.RecordObject(graph, "Upgrade Frame Animation Phase 4 Sample");
|
||||
var idleNode = GetOrAddNode(graph, IdleNodeId, idleClip, "待机 Node");
|
||||
var blinkNode = GetOrAddNode(graph, BlinkNodeId, blinkClip, "眨眼 Node");
|
||||
var sharedNode = graph.Nodes.FirstOrDefault(node => node != null && node.InternalId == SharedNodeId);
|
||||
if (sharedNode == null)
|
||||
{
|
||||
sharedNode = new AnimationNode(sharedClip.Id, "Shared Idle Node",
|
||||
FrameClipEndBehavior.Loop, internalId: SharedNodeId);
|
||||
graph.AddNode(sharedNode);
|
||||
}
|
||||
GetOrAddEdge(graph, IdleEdgeId, idleNode, sharedNode);
|
||||
GetOrAddEdge(graph, BlinkEdgeId, blinkNode, sharedNode);
|
||||
GetOrAddFlow(graph, IdleFlowId, "待机到共享 Idle", idleNode,
|
||||
new Color(0.24f, 0.65f, 1f));
|
||||
GetOrAddFlow(graph, BlinkFlowId, "眨眼到共享 Idle", blinkNode,
|
||||
new Color(1f, 0.62f, 0.24f));
|
||||
graph.EditorData.GetOrCreateNodeData(IdleNodeId, new Vector2(0f, 0f));
|
||||
graph.EditorData.GetOrCreateNodeData(BlinkNodeId, new Vector2(0f, 220f));
|
||||
graph.EditorData.GetOrCreateNodeData(SharedNodeId, new Vector2(360f, 110f));
|
||||
EditorUtility.SetDirty(graph);
|
||||
AssetDatabase.SaveAssets();
|
||||
Selection.activeObject = graph;
|
||||
Debug.Log("Frame Animation Phase 4 sample upgraded without rebuilding existing assets or sources.");
|
||||
}
|
||||
|
||||
private static AnimationNode GetOrAddNode(
|
||||
FrameAnimationGraph graph,
|
||||
string internalId,
|
||||
FrameClip clip,
|
||||
string displayName)
|
||||
{
|
||||
var node = graph.Nodes.FirstOrDefault(item => item != null && item.InternalId == internalId);
|
||||
if (node != null)
|
||||
{
|
||||
return node;
|
||||
}
|
||||
node = new AnimationNode(clip.Id, displayName, internalId: internalId);
|
||||
graph.AddNode(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
private static void GetOrAddEdge(
|
||||
FrameAnimationGraph graph,
|
||||
string internalId,
|
||||
AnimationNode from,
|
||||
AnimationNode to)
|
||||
{
|
||||
if (graph.Edges.Any(edge => edge != null && edge.InternalId == internalId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
graph.AddEdge(new AnimationEdge(from.InternalId, to.InternalId, internalId));
|
||||
}
|
||||
|
||||
private static void GetOrAddFlow(
|
||||
FrameAnimationGraph graph,
|
||||
string id,
|
||||
string displayName,
|
||||
AnimationNode entry,
|
||||
Color color)
|
||||
{
|
||||
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == id);
|
||||
if (flow == null && FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id))
|
||||
{
|
||||
flow = new AnimationFlow(id, displayName, entry.InternalId);
|
||||
graph.AddFlow(flow);
|
||||
}
|
||||
if (flow != null)
|
||||
{
|
||||
graph.EditorData.GetOrCreateFlowData(flow.Id, color);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtendForWorkspaceSample(FrameAnimationGraph graph)
|
||||
{
|
||||
var imported = graph.Clips.First(clip => clip.ImportInfo?.SourceTagName == "待机");
|
||||
var manual = ScriptableObject.CreateInstance<FrameClip>();
|
||||
manual.name = "ManualWorkspaceSample";
|
||||
manual.Configure(
|
||||
"ManualWorkspaceSample",
|
||||
"Graph 内 Manual 样例",
|
||||
imported.Frames.Take(1).Select(frame => new FrameAnimationFrame(
|
||||
frame.Sprite, frame.DurationMs, string.Empty, -1)),
|
||||
1f,
|
||||
FrameClipEndBehavior.HoldLastFrame);
|
||||
AssetDatabase.AddObjectToAsset(manual, graph);
|
||||
|
||||
var clips = graph.Clips.Concat(new[] { manual }).ToList();
|
||||
var external = AssetDatabase.LoadAssetAtPath<FrameClip>(
|
||||
"Assets/GameContent/Test/FrameAnimation/Idle.asset");
|
||||
if (external != null && clips.All(clip => clip.Id != external.Id))
|
||||
{
|
||||
clips.Add(external);
|
||||
}
|
||||
|
||||
var importedNode = new AnimationNode(imported.Id, "Imported 待机");
|
||||
var terminalClip = external != null && clips.Contains(external) ? external : manual;
|
||||
var terminalNode = new AnimationNode(
|
||||
terminalClip.Id,
|
||||
"共享 Manual 终点",
|
||||
endBehaviorOverride: FrameClipEndBehavior.Loop);
|
||||
var edge = new AnimationEdge(importedNode.InternalId, terminalNode.InternalId);
|
||||
var flow = new AnimationFlow(
|
||||
"WorkspaceSampleFlow",
|
||||
"工作台引用定位样例",
|
||||
importedNode.InternalId);
|
||||
graph.Configure(
|
||||
graph.Id,
|
||||
graph.DisplayName,
|
||||
clips,
|
||||
new[] { importedNode, terminalNode },
|
||||
new[] { edge },
|
||||
new[] { flow },
|
||||
flow.Id);
|
||||
}
|
||||
|
||||
private static JObject CreateFrame(string name, int x, int duration)
|
||||
{
|
||||
return new JObject
|
||||
{
|
||||
["filename"] = name,
|
||||
["frame"] = new JObject { ["x"] = x, ["y"] = 0, ["w"] = 2, ["h"] = 2 },
|
||||
["rotated"] = false,
|
||||
["trimmed"] = false,
|
||||
["spriteSourceSize"] = new JObject { ["x"] = 0, ["y"] = 0, ["w"] = 2, ["h"] = 2 },
|
||||
["sourceSize"] = new JObject { ["w"] = 2, ["h"] = 2 },
|
||||
["duration"] = duration
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateObjectJson(string imageName, string firstTag, string secondTag)
|
||||
{
|
||||
var root = CreateRoot(imageName, firstTag, secondTag);
|
||||
root["frames"] = new JObject
|
||||
{
|
||||
["中文帧_00"] = WithoutFilename(CreateFrame("中文帧_00", 0, 160)),
|
||||
["中文帧_01"] = WithoutFilename(CreateFrame("中文帧_01", 2, 90))
|
||||
};
|
||||
return root.ToString(Formatting.Indented);
|
||||
}
|
||||
|
||||
private static string CreateArrayJson(string imageName, string firstTag, string secondTag)
|
||||
{
|
||||
var root = CreateRoot(imageName, firstTag, secondTag);
|
||||
root["frames"] = new JArray(
|
||||
CreateFrame("自动帧_00", 0, 140),
|
||||
CreateFrame("自动帧_01", 2, 110));
|
||||
return root.ToString(Formatting.Indented);
|
||||
}
|
||||
|
||||
private static JObject CreateRoot(string imageName, string firstTag, string secondTag)
|
||||
{
|
||||
return new JObject
|
||||
{
|
||||
["meta"] = new JObject
|
||||
{
|
||||
["image"] = imageName,
|
||||
["size"] = new JObject { ["w"] = 4, ["h"] = 2 },
|
||||
["frameTags"] = new JArray(
|
||||
new JObject
|
||||
{
|
||||
["name"] = firstTag, ["from"] = 0, ["to"] = 0, ["direction"] = "forward"
|
||||
},
|
||||
new JObject
|
||||
{
|
||||
["name"] = secondTag, ["from"] = 0, ["to"] = 1, ["direction"] = "pingpong"
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static JObject WithoutFilename(JObject frame)
|
||||
{
|
||||
frame.Remove("filename");
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static void WriteTexture(string path)
|
||||
{
|
||||
var texture = new Texture2D(4, 2, TextureFormat.RGBA32, false);
|
||||
texture.SetPixels(new[]
|
||||
{
|
||||
Color.cyan, Color.cyan, Color.magenta, Color.magenta,
|
||||
Color.cyan, Color.cyan, Color.magenta, Color.magenta
|
||||
});
|
||||
texture.Apply();
|
||||
File.WriteAllBytes(path, texture.EncodeToPNG());
|
||||
UnityEngine.Object.DestroyImmediate(texture);
|
||||
}
|
||||
|
||||
private static void WriteTextAsset(string path, string content)
|
||||
{
|
||||
File.WriteAllText(path, content, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static void EnsureFolder(string path)
|
||||
{
|
||||
var segments = path.Split('/');
|
||||
var current = segments[0];
|
||||
for (var index = 1; index < segments.Length; index++)
|
||||
{
|
||||
var next = current + "/" + segments[index];
|
||||
if (!AssetDatabase.IsValidFolder(next))
|
||||
{
|
||||
AssetDatabase.CreateFolder(current, segments[index]);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 291df5abf0a29fd4ca1cb0b86eb24a9e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,142 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
public static class FrameAnimationPreviewSampleBuilder
|
||||
{
|
||||
private const string Folder = "Assets/GameContent/Test/FrameAnimation/Preview";
|
||||
private const string GraphPath = Folder + "/PreviewSampleGraph.asset";
|
||||
private const string TexturePath = "Assets/GameContent/Test/AnimatorRaw/手接树叶特写.png";
|
||||
|
||||
[MenuItem("Tools/Frame Animation/Create Phase 5 Preview Sample")]
|
||||
public static void BuildFromCommandLine()
|
||||
{
|
||||
if (AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(GraphPath) != null)
|
||||
{
|
||||
Debug.Log($"Frame Animation preview sample already exists; preserved without overwrite: {GraphPath}");
|
||||
return;
|
||||
}
|
||||
EnsureFolder(Folder);
|
||||
var sprites = AssetDatabase.LoadAllAssetsAtPath(TexturePath).OfType<Sprite>()
|
||||
.OrderBy(sprite => sprite.name, StringComparer.Ordinal).ToArray();
|
||||
if (sprites.Length < 6)
|
||||
{
|
||||
throw new InvalidOperationException("第五阶段预览样例需要至少 6 个已切分 Sprite。");
|
||||
}
|
||||
|
||||
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
|
||||
graph.name = "PreviewSampleGraph";
|
||||
AssetDatabase.CreateAsset(graph, GraphPath);
|
||||
var action = CreateClip(graph, "PreviewAction", "Preview Action", FrameClipEndBehavior.HoldLastFrame,
|
||||
new[]
|
||||
{
|
||||
new FrameAnimationFrame(sprites[0], 100, sprites[0].name, 0),
|
||||
new FrameAnimationFrame(sprites[1], 160, sprites[1].name, 1),
|
||||
new FrameAnimationFrame(sprites[2], 220, sprites[2].name, 2)
|
||||
});
|
||||
var withEmpty = CreateClip(graph, "PreviewEmpty", "Preview Empty Frame", FrameClipEndBehavior.HoldLastFrame,
|
||||
new[]
|
||||
{
|
||||
new FrameAnimationFrame(sprites[3], 120, sprites[3].name, 3),
|
||||
new FrameAnimationFrame(null, 300, "empty", -1),
|
||||
new FrameAnimationFrame(sprites[4], 120, sprites[4].name, 4)
|
||||
});
|
||||
var idle = CreateClip(graph, "PreviewIdle", "Preview Idle Loop", FrameClipEndBehavior.Loop,
|
||||
new[]
|
||||
{
|
||||
new FrameAnimationFrame(sprites[4], 140, sprites[4].name, 4),
|
||||
new FrameAnimationFrame(sprites[5], 140, sprites[5].name, 5)
|
||||
});
|
||||
|
||||
var finiteA = new AnimationNode(action.Id, "Finite Action", speedOverride: 2f,
|
||||
internalId: "51000000000000000000000000000001");
|
||||
var finiteB = new AnimationNode(withEmpty.Id, "Finite Empty",
|
||||
endBehaviorOverride: FrameClipEndBehavior.HoldLastFrame,
|
||||
internalId: "51000000000000000000000000000002");
|
||||
var loopA = new AnimationNode(action.Id, "Loop Intro", internalId: "51000000000000000000000000000003");
|
||||
var loopB = new AnimationNode(idle.Id, "Loop Terminal",
|
||||
endBehaviorOverride: FrameClipEndBehavior.Loop,
|
||||
internalId: "51000000000000000000000000000004");
|
||||
var blocked = new AnimationNode(withEmpty.Id, "Zero Speed Block", speedOverride: 0f,
|
||||
internalId: "51000000000000000000000000000005");
|
||||
var clear = new AnimationNode(action.Id, "Clear Terminal",
|
||||
endBehaviorOverride: FrameClipEndBehavior.Clear,
|
||||
internalId: "51000000000000000000000000000006");
|
||||
var hidden = new AnimationNode(idle.Id, "Hide Terminal",
|
||||
endBehaviorOverride: FrameClipEndBehavior.HideTarget,
|
||||
internalId: "51000000000000000000000000000007");
|
||||
var finiteFlow = new AnimationFlow("PreviewFiniteFlow", "Finite Flow", finiteA.InternalId);
|
||||
var loopFlow = new AnimationFlow("PreviewLoopFlow", "Loop Flow", loopA.InternalId);
|
||||
var blockedFlow = new AnimationFlow("PreviewBlockedFlow", "Zero Speed Flow", blocked.InternalId);
|
||||
var clearFlow = new AnimationFlow("PreviewClearFlow", "Clear Flow", clear.InternalId);
|
||||
var hideFlow = new AnimationFlow("PreviewHideFlow", "Hide Flow", hidden.InternalId);
|
||||
graph.Configure(
|
||||
"PreviewSampleGraph",
|
||||
"Phase 5 Preview Sample",
|
||||
new[] { action, withEmpty, idle },
|
||||
new[] { finiteA, finiteB, loopA, loopB, blocked, clear, hidden },
|
||||
new[]
|
||||
{
|
||||
new AnimationEdge(finiteA.InternalId, finiteB.InternalId, "52000000000000000000000000000001"),
|
||||
new AnimationEdge(loopA.InternalId, loopB.InternalId, "52000000000000000000000000000002")
|
||||
},
|
||||
new[] { finiteFlow, loopFlow, blockedFlow, clearFlow, hideFlow },
|
||||
finiteFlow.Id);
|
||||
|
||||
var positions = new[]
|
||||
{
|
||||
(finiteA, new Vector2(80f, 80f)), (finiteB, new Vector2(420f, 80f)),
|
||||
(loopA, new Vector2(80f, 320f)), (loopB, new Vector2(420f, 320f)),
|
||||
(blocked, new Vector2(80f, 560f)), (clear, new Vector2(420f, 560f)),
|
||||
(hidden, new Vector2(760f, 560f))
|
||||
};
|
||||
foreach (var item in positions)
|
||||
{
|
||||
graph.EditorData.GetOrCreateNodeData(item.Item1.InternalId, item.Item2);
|
||||
}
|
||||
var colors = new[]
|
||||
{
|
||||
new Color(0.25f, 0.65f, 0.95f), new Color(0.55f, 0.4f, 0.9f),
|
||||
new Color(0.9f, 0.55f, 0.2f), new Color(0.3f, 0.75f, 0.5f), new Color(0.85f, 0.35f, 0.45f)
|
||||
};
|
||||
var flows = graph.Flows.ToArray();
|
||||
for (var index = 0; index < flows.Length; index++)
|
||||
{
|
||||
graph.EditorData.GetOrCreateFlowData(flows[index].Id, colors[index]);
|
||||
}
|
||||
EditorUtility.SetDirty(graph);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
Debug.Log($"Frame Animation phase 5 preview sample created: {GraphPath}");
|
||||
}
|
||||
|
||||
private static FrameClip CreateClip(
|
||||
FrameAnimationGraph graph,
|
||||
string id,
|
||||
string displayName,
|
||||
FrameClipEndBehavior endBehavior,
|
||||
FrameAnimationFrame[] frames)
|
||||
{
|
||||
var clip = ScriptableObject.CreateInstance<FrameClip>();
|
||||
clip.name = id;
|
||||
clip.Configure(id, displayName, frames, 1f, endBehavior);
|
||||
AssetDatabase.AddObjectToAsset(clip, graph);
|
||||
return clip;
|
||||
}
|
||||
|
||||
private static void EnsureFolder(string path)
|
||||
{
|
||||
var segments = path.Split('/');
|
||||
var current = segments[0];
|
||||
for (var index = 1; index < segments.Length; index++)
|
||||
{
|
||||
var next = current + "/" + segments[index];
|
||||
if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, segments[index]);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f280e66f096a410eaf3eae8cd9c01134
|
||||
@@ -1,223 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
public static class FrameAnimationRuntimeSampleBuilder
|
||||
{
|
||||
private const string SourceTexturePath =
|
||||
"Assets/GameContent/Test/AnimatorRaw/手接树叶特写.png";
|
||||
private const string SampleFolder = "Assets/GameContent/Test/FrameAnimation";
|
||||
private const string IntroClipPath = SampleFolder + "/Intro.asset";
|
||||
private const string IdleClipPath = SampleFolder + "/Idle.asset";
|
||||
private const string FlowGraphPath = SampleFolder + "/FlowSampleGraph.asset";
|
||||
private const string DirectGraphPath = SampleFolder + "/DirectSampleGraph.asset";
|
||||
private const string ScenePath = "Assets/Scenes/FrameAnimationRuntimeTest.unity";
|
||||
|
||||
[MenuItem("Tools/Frame Animation/Rebuild Runtime Sample")]
|
||||
private static void BuildFromMenu()
|
||||
{
|
||||
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BuildFromCommandLine();
|
||||
}
|
||||
|
||||
public static void BuildFromCommandLine()
|
||||
{
|
||||
EnsureFolder(SampleFolder);
|
||||
var sprites = AssetDatabase.LoadAllAssetsAtPath(SourceTexturePath)
|
||||
.OfType<Sprite>()
|
||||
.OrderBy(sprite => ExtractFrameNumber(sprite.name))
|
||||
.ToArray();
|
||||
if (sprites.Length < 21)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"样例源 Texture 需要至少 21 个 Sprite,当前只找到 {sprites.Length} 个。");
|
||||
}
|
||||
|
||||
var introClip = CreateOrLoadAsset<FrameClip>(IntroClipPath);
|
||||
introClip.Configure(
|
||||
"Intro",
|
||||
"Intro",
|
||||
CreateFrames(sprites, 0, 5),
|
||||
1f,
|
||||
FrameClipEndBehavior.HoldLastFrame);
|
||||
EditorUtility.SetDirty(introClip);
|
||||
|
||||
var idleClip = CreateOrLoadAsset<FrameClip>(IdleClipPath);
|
||||
idleClip.Configure(
|
||||
"Idle",
|
||||
"Idle",
|
||||
CreateFrames(sprites, 5, 16),
|
||||
1f,
|
||||
FrameClipEndBehavior.Loop);
|
||||
EditorUtility.SetDirty(idleClip);
|
||||
|
||||
var flowGraph = CreateOrLoadAsset<FrameAnimationGraph>(FlowGraphPath);
|
||||
var introNodeId = FindExistingNodeId(flowGraph, introClip.Id);
|
||||
var idleNodeId = FindExistingNodeId(flowGraph, idleClip.Id);
|
||||
var introNode = new AnimationNode(introClip.Id, "Intro", internalId: introNodeId);
|
||||
var idleNode = new AnimationNode(
|
||||
idleClip.Id,
|
||||
"Idle Loop",
|
||||
FrameClipEndBehavior.Loop,
|
||||
internalId: idleNodeId);
|
||||
var flow = new AnimationFlow("IntroToIdle", "Intro To Idle", introNode.InternalId);
|
||||
flowGraph.Configure(
|
||||
"FlowSampleGraph",
|
||||
"Flow Sample Graph",
|
||||
new[] { introClip, idleClip },
|
||||
new[] { introNode, idleNode },
|
||||
new[] { new AnimationEdge(introNode.InternalId, idleNode.InternalId) },
|
||||
new[] { flow },
|
||||
flow.Id);
|
||||
EditorUtility.SetDirty(flowGraph);
|
||||
|
||||
var directGraph = CreateOrLoadAsset<FrameAnimationGraph>(DirectGraphPath);
|
||||
directGraph.Configure(
|
||||
"DirectSampleGraph",
|
||||
"Direct Sample Graph",
|
||||
new[] { introClip, idleClip },
|
||||
Array.Empty<AnimationNode>(),
|
||||
Array.Empty<AnimationEdge>(),
|
||||
Array.Empty<AnimationFlow>(),
|
||||
idleClip.Id);
|
||||
EditorUtility.SetDirty(directGraph);
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
BuildScene(flowGraph, directGraph);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
Debug.Log($"Frame Animation runtime sample rebuilt: {ScenePath}");
|
||||
}
|
||||
|
||||
private static IEnumerable<FrameAnimationFrame> CreateFrames(
|
||||
IReadOnlyList<Sprite> sprites,
|
||||
int startIndex,
|
||||
int count)
|
||||
{
|
||||
for (var offset = 0; offset < count; offset++)
|
||||
{
|
||||
var sourceIndex = startIndex + offset;
|
||||
var sprite = sprites[sourceIndex];
|
||||
yield return new FrameAnimationFrame(sprite, 250, sprite.name, sourceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildScene(
|
||||
FrameAnimationGraph flowGraph,
|
||||
FrameAnimationGraph directGraph)
|
||||
{
|
||||
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
||||
|
||||
var cameraObject = new GameObject("Main Camera", typeof(Camera));
|
||||
cameraObject.tag = "MainCamera";
|
||||
cameraObject.transform.position = new Vector3(0f, 0f, -10f);
|
||||
var camera = cameraObject.GetComponent<Camera>();
|
||||
camera.orthographic = true;
|
||||
camera.orthographicSize = 6f;
|
||||
camera.clearFlags = CameraClearFlags.SolidColor;
|
||||
camera.backgroundColor = new Color(0.08f, 0.09f, 0.12f, 1f);
|
||||
|
||||
var spriteObject = new GameObject("Flow Sample - SpriteRenderer", typeof(SpriteRenderer));
|
||||
spriteObject.transform.position = new Vector3(-3.5f, 0f, 0f);
|
||||
spriteObject.transform.localScale = Vector3.one * 0.45f;
|
||||
var spritePlayer = spriteObject.AddComponent<FrameAnimationPlayer>();
|
||||
spritePlayer.ConfigureForAuthoring(flowGraph, true, 1f);
|
||||
|
||||
var canvasObject = new GameObject(
|
||||
"Direct Clip Sample Canvas",
|
||||
typeof(Canvas),
|
||||
typeof(CanvasScaler),
|
||||
typeof(GraphicRaycaster));
|
||||
var canvas = canvasObject.GetComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
var scaler = canvasObject.GetComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1280f, 720f);
|
||||
|
||||
var imageObject = new GameObject(
|
||||
"Direct Idle - Image",
|
||||
typeof(RectTransform),
|
||||
typeof(CanvasRenderer),
|
||||
typeof(Image));
|
||||
imageObject.transform.SetParent(canvasObject.transform, false);
|
||||
var rectTransform = imageObject.GetComponent<RectTransform>();
|
||||
rectTransform.sizeDelta = new Vector2(384f, 216f);
|
||||
rectTransform.anchoredPosition = new Vector2(300f, 0f);
|
||||
var image = imageObject.GetComponent<Image>();
|
||||
image.preserveAspect = true;
|
||||
var imagePlayer = imageObject.AddComponent<FrameAnimationPlayer>();
|
||||
imagePlayer.ConfigureForAuthoring(directGraph, true, 1f);
|
||||
|
||||
EditorSceneManager.MarkSceneDirty(scene);
|
||||
if (!EditorSceneManager.SaveScene(scene, ScenePath))
|
||||
{
|
||||
throw new InvalidOperationException($"无法保存样例场景:{ScenePath}");
|
||||
}
|
||||
}
|
||||
|
||||
private static T CreateOrLoadAsset<T>(string path) where T : ScriptableObject
|
||||
{
|
||||
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
|
||||
if (asset != null)
|
||||
{
|
||||
return asset;
|
||||
}
|
||||
|
||||
asset = ScriptableObject.CreateInstance<T>();
|
||||
AssetDatabase.CreateAsset(asset, path);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private static string FindExistingNodeId(FrameAnimationGraph graph, string clipId)
|
||||
{
|
||||
var existing = graph.Nodes.FirstOrDefault(node => node != null && node.ClipId == clipId);
|
||||
return existing != null && FrameAnimationValueUtility.IsValidInternalId(existing.InternalId)
|
||||
? existing.InternalId
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int ExtractFrameNumber(string spriteName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(spriteName))
|
||||
{
|
||||
return int.MaxValue;
|
||||
}
|
||||
|
||||
var spaceIndex = spriteName.LastIndexOf(' ');
|
||||
var dotIndex = spriteName.LastIndexOf('.');
|
||||
if (spaceIndex >= 0 && dotIndex > spaceIndex &&
|
||||
int.TryParse(spriteName.Substring(spaceIndex + 1, dotIndex - spaceIndex - 1), out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
|
||||
private static void EnsureFolder(string path)
|
||||
{
|
||||
var segments = path.Split('/');
|
||||
var current = segments[0];
|
||||
for (var index = 1; index < segments.Length; index++)
|
||||
{
|
||||
var next = current + "/" + segments[index];
|
||||
if (!AssetDatabase.IsValidFolder(next))
|
||||
{
|
||||
AssetDatabase.CreateFolder(current, segments[index]);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 679ccd2bc27e67c468f002c1282b6045
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AibisDream.Editor;
|
||||
using UnityEditor;
|
||||
using UnityEditorInternal;
|
||||
using UnityEngine;
|
||||
@@ -16,7 +17,7 @@ namespace AibisDream.FrameAnimation.Editor
|
||||
private FrameClipImportPreview importPreview;
|
||||
private double lastUpdateTime;
|
||||
|
||||
[MenuItem("Window/AibisDream/Frame Animation/Frame Clip Editor")]
|
||||
[MenuItem(AibisEditorMenus.FrameClipEditor)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
GetWindow<FrameClipEditorWindow>("Frame Clip Editor");
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
/// <summary>
|
||||
/// 生成与 SpriteNoiseGlitch shader 三阶段对应的程序化音效。
|
||||
/// 菜单: Tools > Generate Glitch Noise Audio
|
||||
/// </summary>
|
||||
public static class GlitchNoiseAudioGenerator
|
||||
{
|
||||
private const int SampleRate = 44100;
|
||||
private const float Duration = 4f; // 每段音效时长(秒),便于循环使用
|
||||
private const string OutputFolder = "Assets/RawResources/Audio/GlitchNoise";
|
||||
|
||||
[MenuItem("Tools/Generate Glitch Noise Audio")]
|
||||
public static void Generate()
|
||||
{
|
||||
string dir = Path.Combine(Application.dataPath, "RawResources", "Audio", "GlitchNoise");
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
GenerateStage1(dir);
|
||||
GenerateStage2(dir);
|
||||
GenerateStage3(dir);
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
Debug.Log($"[GlitchNoise] 已生成三阶段音效至 Assets/RawResources/Audio/GlitchNoise");
|
||||
}
|
||||
|
||||
/// <summary>Stage 1: 轻微静态噪点 - 柔和白噪声</summary>
|
||||
private static void GenerateStage1(string dir)
|
||||
{
|
||||
int samples = (int)(SampleRate * Duration);
|
||||
float[] data = new float[samples];
|
||||
var rnd = new System.Random(12345);
|
||||
|
||||
float gain = 0.12f;
|
||||
for (int i = 0; i < samples; i++)
|
||||
{
|
||||
data[i] = ((float)rnd.NextDouble() * 2f - 1f) * gain;
|
||||
}
|
||||
|
||||
SaveWav(dir, "GlitchNoise_Stage1_Light.wav", data);
|
||||
}
|
||||
|
||||
/// <summary>Stage 2: 中等 - 更多噪点 + 偶尔 glitch 爆音</summary>
|
||||
private static void GenerateStage2(string dir)
|
||||
{
|
||||
int samples = (int)(SampleRate * Duration);
|
||||
float[] data = new float[samples];
|
||||
var rnd = new System.Random(23456);
|
||||
|
||||
float noiseGain = 0.22f;
|
||||
int glitchInterval = SampleRate / 4; // 约每 0.25 秒一次 glitch 爆音
|
||||
|
||||
for (int i = 0; i < samples; i++)
|
||||
{
|
||||
float n = ((float)rnd.NextDouble() * 2f - 1f) * noiseGain;
|
||||
|
||||
// 随机 glitch 爆音
|
||||
if (i > 0 && i % glitchInterval < 120)
|
||||
{
|
||||
float burst = ((float)rnd.NextDouble() * 2f - 1f) * 0.5f;
|
||||
n += burst * (1f - (i % glitchInterval) / 120f);
|
||||
}
|
||||
|
||||
data[i] = Mathf.Clamp(n, -1f, 1f);
|
||||
}
|
||||
|
||||
SaveWav(dir, "GlitchNoise_Stage2_Medium.wav", data);
|
||||
}
|
||||
|
||||
/// <summary>Stage 3: 严重 - 强烈噪点 + 频繁 glitch + 数字故障感</summary>
|
||||
private static void GenerateStage3(string dir)
|
||||
{
|
||||
int samples = (int)(SampleRate * Duration);
|
||||
float[] data = new float[samples];
|
||||
var rnd = new System.Random(34567);
|
||||
|
||||
float noiseGain = 0.4f;
|
||||
int blockSize = 2205; // 约 0.05 秒一块
|
||||
int glitchBlockEvery = 4;
|
||||
|
||||
for (int i = 0; i < samples; i++)
|
||||
{
|
||||
int block = i / blockSize;
|
||||
float n = ((float)rnd.NextDouble() * 2f - 1f) * noiseGain;
|
||||
|
||||
// 块状 glitch:整块随机反转/爆音
|
||||
if (block % glitchBlockEvery == 0)
|
||||
{
|
||||
int posInBlock = i % blockSize;
|
||||
float t = (float)posInBlock / blockSize;
|
||||
n += ((float)rnd.NextDouble() * 2f - 1f) * 0.6f * (1f - t);
|
||||
}
|
||||
|
||||
// 随机“卡顿”短静音后爆音
|
||||
if (rnd.NextDouble() < 0.0003)
|
||||
{
|
||||
int silenceLen = 100 + rnd.Next(300);
|
||||
int end = Mathf.Min(i + silenceLen, samples);
|
||||
for (int j = i; j < end; j++)
|
||||
{
|
||||
data[j] = j == end - 1 ? ((float)rnd.NextDouble() * 2f - 1f) * 0.8f : 0f;
|
||||
}
|
||||
i = end - 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
data[i] = Mathf.Clamp(n, -1f, 1f);
|
||||
}
|
||||
}
|
||||
|
||||
SaveWav(dir, "GlitchNoise_Stage3_Heavy.wav", data);
|
||||
}
|
||||
|
||||
private static void SaveWav(string dir, string filename, float[] samples)
|
||||
{
|
||||
string path = Path.Combine(dir, filename);
|
||||
using (var fs = new FileStream(path, FileMode.Create))
|
||||
using (var bw = new BinaryWriter(fs))
|
||||
{
|
||||
// RIFF header
|
||||
bw.Write(new[] { 'R', 'I', 'F', 'F' });
|
||||
int dataSize = samples.Length * 2; // 16-bit
|
||||
bw.Write(36 + dataSize);
|
||||
bw.Write(new[] { 'W', 'A', 'V', 'E' });
|
||||
|
||||
// fmt chunk
|
||||
bw.Write(new[] { 'f', 'm', 't', ' ' });
|
||||
bw.Write(16); // chunk size
|
||||
bw.Write((short)1); // PCM
|
||||
bw.Write((short)1); // mono
|
||||
bw.Write(SampleRate);
|
||||
bw.Write(SampleRate * 2);
|
||||
bw.Write((short)2);
|
||||
bw.Write((short)16);
|
||||
|
||||
// data chunk
|
||||
bw.Write(new[] { 'd', 'a', 't', 'a' });
|
||||
bw.Write(dataSize);
|
||||
|
||||
foreach (float s in samples)
|
||||
{
|
||||
short sample = (short)Mathf.Clamp((int)(s * 32767), -32768, 32767);
|
||||
bw.Write(sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd5ec77f7adcc584ebc901ac1f73fab6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AibisDream.Editor;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
@@ -9,7 +10,7 @@ namespace AibisDream.EditorTools
|
||||
{
|
||||
public class HandlerNameCollectorWindow : EditorWindow
|
||||
{
|
||||
private const string WindowTitle = "Handler Name Collector";
|
||||
private const string WindowTitle = "名称收集器";
|
||||
|
||||
private HandlerNameCache _cache;
|
||||
|
||||
@@ -30,7 +31,7 @@ namespace AibisDream.EditorTools
|
||||
|
||||
private HandlerNameCacheEntry _selectedEntry;
|
||||
|
||||
[MenuItem("Tools/Handler Name Collector")]
|
||||
[MenuItem(AibisEditorMenus.NameCollector)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<HandlerNameCollectorWindow>(WindowTitle);
|
||||
@@ -38,12 +39,6 @@ namespace AibisDream.EditorTools
|
||||
window.Show();
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Timeline Name Collector", false, 101)]
|
||||
public static void ShowWindowLegacyMenu()
|
||||
{
|
||||
ShowWindow();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
LoadCacheIfNeeded();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using UnityEditor;
|
||||
using AibisDream.Editor;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
@@ -7,7 +8,7 @@ namespace AibisDream.SystemEditor
|
||||
{
|
||||
private const string BLOCK_SHAPE_PREFAB_PATH = "Assets/Prefabs/BlockPuzzle/BlockShape.prefab";
|
||||
|
||||
[MenuItem("GameObject/Block Puzzle/Shape")]
|
||||
[MenuItem(AibisEditorMenus.BlockPuzzleShape)]
|
||||
public static BlockShape CreateBlockPuzzleShape()
|
||||
{
|
||||
var prefabAsset =AssetDatabase.LoadAssetAtPath<GameObject>(BLOCK_SHAPE_PREFAB_PATH);
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using JetBrains.Annotations;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
{
|
||||
public class AnimatorClipLink : EditorWindow
|
||||
{
|
||||
[SerializeField] private VisualTreeAsset visualTreeAsset;
|
||||
|
||||
private ObjectField _animatorField;
|
||||
private Label _animatorMessage;
|
||||
private ListView _stateInfoView;
|
||||
|
||||
private TextField _clipsPathField;
|
||||
private Label _clipsMessage;
|
||||
private ListView _clipsNameList;
|
||||
|
||||
private HelpBox _matchMessageBox;
|
||||
|
||||
#region 部分缓存
|
||||
|
||||
private static AnimatorController _animatorCache;
|
||||
private static string _clipsPathCache;
|
||||
|
||||
private ChildAnimatorState[] _statesCache;
|
||||
private string[] _clipsCache;
|
||||
|
||||
private bool _isChecked;
|
||||
private bool _allMatched;
|
||||
private bool _isAllLoad;
|
||||
|
||||
#endregion
|
||||
|
||||
[MenuItem("Tools/AnimatorClipLink")]
|
||||
public static void ShowExample()
|
||||
{
|
||||
AnimatorClipLink wnd = GetWindow<AnimatorClipLink>();
|
||||
wnd.titleContent = new GUIContent("AnimatorClipLink");
|
||||
}
|
||||
|
||||
public void CreateGUI()
|
||||
{
|
||||
// Each editor window contains a root VisualElement object
|
||||
VisualElement root = rootVisualElement;
|
||||
|
||||
// Instantiate UXML
|
||||
root.Add(visualTreeAsset.Instantiate());
|
||||
|
||||
// 注册一些事件
|
||||
_animatorField = root.Q<ObjectField>("animator-field");
|
||||
_animatorField.RegisterValueChangedCallback(OnAnimatorChanged);
|
||||
_animatorMessage = root.Q<Label>("animator-message");
|
||||
_stateInfoView = root.Q<ListView>("state-info-list");
|
||||
|
||||
_clipsPathField = root.Q<TextField>("clips-path-field");
|
||||
_clipsPathField.RegisterValueChangedCallback(OnClipsPathChanged);
|
||||
_clipsMessage = root.Q<Label>("clips-message");
|
||||
_clipsNameList = root.Q<ListView>("clips-name-list");
|
||||
|
||||
_matchMessageBox = root.Q<HelpBox>("match-message-box");
|
||||
|
||||
root.Q<Button>("import-merge-button").clicked += ImportMerge;
|
||||
|
||||
// 初始化
|
||||
_animatorMessage.text = "请添加AnimatorController";
|
||||
_clipsMessage.text = "请输入Clips路径";
|
||||
// 加载AnimatorState
|
||||
if (_animatorCache != null)
|
||||
{
|
||||
_animatorField.value = _animatorCache;
|
||||
LoadAnimatorController(_animatorCache);
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateStateInfos(Array.Empty<ChildAnimatorState>());
|
||||
}
|
||||
|
||||
// 加载路径下的Clips
|
||||
if (!string.IsNullOrWhiteSpace(_clipsPathCache))
|
||||
{
|
||||
_clipsPathField.value = _clipsPathCache;
|
||||
LoadClipPath(_clipsPathCache);
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateClipInFolder(Array.Empty<string>());
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateStateInfos(ChildAnimatorState[] states)
|
||||
{
|
||||
_statesCache = states;
|
||||
// 总条目
|
||||
var stateList = states.ToList();
|
||||
|
||||
_stateInfoView.makeItem = MakeItem;
|
||||
_stateInfoView.bindItem = BindItem;
|
||||
_stateInfoView.itemsSource = stateList;
|
||||
_stateInfoView.selectionType = UnityEngine.UIElements.SelectionType.None;
|
||||
|
||||
MatchStateAndClip();
|
||||
return;
|
||||
|
||||
// 链接
|
||||
void BindItem(VisualElement item, int index)
|
||||
{
|
||||
if (index >= stateList.Count) return;
|
||||
|
||||
var state = stateList[index];
|
||||
if (item is ObjectField field)
|
||||
{
|
||||
field.value = state.state.motion;
|
||||
field.label = state.state.name;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建
|
||||
VisualElement MakeItem()
|
||||
{
|
||||
var item = new ObjectField
|
||||
{
|
||||
objectType = typeof(AnimationClip)
|
||||
};
|
||||
item.SetEnabled(false);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAnimatorChanged(ChangeEvent<Object> evt)
|
||||
{
|
||||
if (evt.newValue is AnimatorController animatorController)
|
||||
{
|
||||
LoadAnimatorController(animatorController);
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadAnimatorController(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadAnimatorController([CanBeNull] AnimatorController animatorController)
|
||||
{
|
||||
// 判断新的值是不是空的,是就清空State信息
|
||||
if (animatorController == null)
|
||||
{
|
||||
_animatorCache = null;
|
||||
_animatorMessage.text = "AnimatorController 为空";
|
||||
// 清空State信息
|
||||
UpdateStateInfos(Array.Empty<ChildAnimatorState>());
|
||||
return;
|
||||
}
|
||||
|
||||
_animatorCache = animatorController;
|
||||
// 值更新的时候,更新State信息
|
||||
var states = animatorController.layers[0].stateMachine.states;
|
||||
_animatorMessage.text = $"BaseLayer共有{states.Length}个State";
|
||||
UpdateStateInfos(states);
|
||||
}
|
||||
|
||||
private void OnClipsPathChanged(ChangeEvent<string> evt)
|
||||
{
|
||||
LoadClipPath(evt.newValue);
|
||||
}
|
||||
|
||||
private void LoadClipPath([CanBeNull] string clipsPath)
|
||||
{
|
||||
_clipsPathCache = clipsPath;
|
||||
// 先判断路径是否存在
|
||||
if (!AssetDatabase.IsValidFolder(clipsPath))
|
||||
{
|
||||
_clipsMessage.text = "路径不存在";
|
||||
// 清空Clip信息
|
||||
UpdateClipInFolder(Array.Empty<string>());
|
||||
return;
|
||||
}
|
||||
|
||||
// 从路径中获取Clip
|
||||
var clipIds = AssetDatabase.FindAssets("t:AnimationClip", new[] { clipsPath });
|
||||
// 获取名称
|
||||
var clipNames = clipIds.Select(AssetDatabase.GUIDToAssetPath)
|
||||
.Where(item => item.EndsWith(".anim"))
|
||||
.ToArray();
|
||||
|
||||
_clipsMessage.text = $"共有{clipNames.Length}个Clip";
|
||||
UpdateClipInFolder(clipNames);
|
||||
}
|
||||
|
||||
private void UpdateClipInFolder(string[] clipNames)
|
||||
{
|
||||
_clipsCache = clipNames;
|
||||
var clipNameList = clipNames.Select(Path.GetFileNameWithoutExtension).ToList();
|
||||
|
||||
_clipsNameList.makeItem = MakeItem;
|
||||
_clipsNameList.bindItem = BindItem;
|
||||
_clipsNameList.itemsSource = clipNameList;
|
||||
|
||||
MatchStateAndClip();
|
||||
return;
|
||||
|
||||
void BindItem(VisualElement item, int index)
|
||||
{
|
||||
if (index >= clipNameList.Count) return;
|
||||
|
||||
var clipName = clipNameList[index];
|
||||
if (item is Label label)
|
||||
{
|
||||
label.text = clipName;
|
||||
}
|
||||
}
|
||||
|
||||
VisualElement MakeItem() => new Label();
|
||||
}
|
||||
|
||||
private void MatchStateAndClip()
|
||||
{
|
||||
if (_statesCache is not { Length: > 0 } || _clipsCache is not { Length: > 0 })
|
||||
{
|
||||
_matchMessageBox.text = "请先选择AnimatorController和Clips路径";
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Warning;
|
||||
_allMatched = false;
|
||||
_isAllLoad = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var clipNames = _clipsCache.Select(Path.GetFileNameWithoutExtension).ToArray();
|
||||
// 匹配state和clips
|
||||
var notMatchStates =
|
||||
(from state in _statesCache where !clipNames.Contains(state.state.name) select state.state.name)
|
||||
.ToList();
|
||||
|
||||
if (notMatchStates.Count == 0)
|
||||
{
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Info;
|
||||
_matchMessageBox.text = "所有State都已经匹配";
|
||||
_allMatched = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Warning;
|
||||
_matchMessageBox.text = $"未匹配的State有{notMatchStates.Count}个:\n{string.Join(", ", notMatchStates)}";
|
||||
_allMatched = false;
|
||||
}
|
||||
|
||||
_isAllLoad = true;
|
||||
_isChecked = false;
|
||||
}
|
||||
|
||||
private void ImportMerge()
|
||||
{
|
||||
if (!_isAllLoad)
|
||||
{
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Warning;
|
||||
_matchMessageBox.text = "请先选择AnimatorController和Clips路径";
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isChecked || _allMatched)
|
||||
{
|
||||
MergeClips();
|
||||
_isChecked = false;
|
||||
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Info;
|
||||
_matchMessageBox.text = "匹配完成";
|
||||
}
|
||||
else
|
||||
{
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Warning;
|
||||
_matchMessageBox.text = "仍有未匹配到Clip的State,确认要继续吗?";
|
||||
_isChecked = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void MergeClips()
|
||||
{
|
||||
var clipNames = _clipsCache.Select(Path.GetFileNameWithoutExtension).ToArray();
|
||||
foreach (var state in _statesCache)
|
||||
{
|
||||
var idx = Array.IndexOf(clipNames, state.state.name);
|
||||
if (idx != -1)
|
||||
{
|
||||
var newClip = AssetDatabase.LoadAssetAtPath<AnimationClip>(_clipsCache[idx]);
|
||||
var oldClip = state.state.motion;
|
||||
|
||||
var settings = AnimationUtility.GetAnimationClipSettings(newClip);
|
||||
settings.loopTime = oldClip.isLooping;
|
||||
AnimationUtility.SetAnimationClipSettings(newClip, settings);
|
||||
|
||||
state.state.motion = newClip;
|
||||
}
|
||||
}
|
||||
|
||||
LoadAnimatorController(_animatorCache);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0182e64fe4acdbf4aae853bf35db2105
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences:
|
||||
- m_ViewDataDictionary: {instanceID: 0}
|
||||
- visualTreeAsset: {fileID: 9197481963319205126, guid: 6fd39d76275817945b272053a20f2c43, type: 3}
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,5 +0,0 @@
|
||||
.custom-label {
|
||||
font-size: 20px;
|
||||
-unity-font-style: bold;
|
||||
color: rgb(68, 138, 255);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec4ca5fe3cf523d4a88253be45a8f5c1
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||
disableValidation: 0
|
||||
@@ -1,22 +0,0 @@
|
||||
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" xsi="http://www.w3.org/2001/XMLSchema-instance" engine="UnityEngine.UIElements" editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../../../UIElementsSchema/UIElements.xsd" editor-extension-mode="True">
|
||||
<Style src="project://database/Assets/Editor/UXML/AnimatorLinker/AnimatorClipLink.uss?fileID=7433441132597879392&guid=ec4ca5fe3cf523d4a88253be45a8f5c1&type=3#AnimatorClipLink" />
|
||||
<ui:Label text="Animator 动画片段导入" class="custom-label" style="align-items: center; justify-content: flex-start; -unity-text-align: upper-center; height: auto;" />
|
||||
<ui:VisualElement style="flex-grow: 1; flex-direction: row; height: auto;">
|
||||
<ui:VisualElement name="Animator" picking-mode="Ignore" style="flex-grow: 1; width: 50%; align-items: stretch;">
|
||||
<ui:Label tabindex="-1" text="Animator" parse-escape-sequences="true" display-tooltip-when-elided="true" />
|
||||
<uie:ObjectField label="动画控制器" name="animator-field" object-type="UnityEngine.AnimatorController" style="align-items: auto;" />
|
||||
<ui:Label tabindex="-1" text="Label" parse-escape-sequences="true" display-tooltip-when-elided="true" name="animator-message" />
|
||||
<ui:ListView focusable="true" name="state-info-list" selection-type="None" show-add-remove-footer="false" />
|
||||
</ui:VisualElement>
|
||||
<ui:VisualElement name="Clips" picking-mode="Ignore" style="flex-grow: 1; width: 50%;">
|
||||
<ui:Label tabindex="-1" text="Clips路径" parse-escape-sequences="true" display-tooltip-when-elided="false" />
|
||||
<ui:TextField label="路径名" name="clips-path-field" style="align-items: auto;" />
|
||||
<ui:Label tabindex="-1" text="Label" parse-escape-sequences="true" display-tooltip-when-elided="true" name="clips-message" />
|
||||
<ui:ListView focusable="true" name="clips-name-list" selection-type="None" show-add-remove-footer="false" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
<ui:VisualElement style="flex-shrink: 0; justify-content: space-around; align-items: center; align-self: stretch; height: auto; flex-direction: row; max-height: none; margin-top: 10px;">
|
||||
<ui:HelpBox text="请先选择AnimatorController和Clips路径" message-type="Warning" name="match-message-box" style="width: 50%; flex-shrink: 0;" />
|
||||
<ui:Button text="保存" parse-escape-sequences="true" display-tooltip-when-elided="true" name="import-merge-button" style="align-items: auto; flex-shrink: 0;" />
|
||||
</ui:VisualElement>
|
||||
</ui:UXML>
|
||||
@@ -1,10 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6fd39d76275817945b272053a20f2c43
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.IO;
|
||||
using AibisDream.Editor;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -36,7 +37,7 @@ namespace AibisDream.SystemEditor
|
||||
private PivotAlignment pivotAlignment = PivotAlignment.Center;
|
||||
private Vector2 customPivot = new Vector2(0.5f, 0.5f);
|
||||
|
||||
[MenuItem("Tools/动画片段生成器(Animation Clip Generator)")]
|
||||
[MenuItem(AibisEditorMenus.AnimationClipGenerator)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
GetWindow<AnimationClipGenerator>("动画片段生成器(Animation Clip Generator)");
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AibisDream.Framework;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
{
|
||||
public class AnimatorBlendTreeParserWindow : EditorWindow
|
||||
{
|
||||
private AnimatorController _selectedController;
|
||||
private List<BlendTreeInfo> _blendTreeInfos;
|
||||
private Vector2 _scrollPosition;
|
||||
private string _resultText = "";
|
||||
private bool _includeNested = false; // 是否包含嵌套混合树,默认不提取
|
||||
private string _defaultStateName = ""; // 控制器默认 State 名称
|
||||
|
||||
[MenuItem("Tools/Animator BlendTree Parser")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
GetWindow<AnimatorBlendTreeParserWindow>("BlendTree Parser");
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// AnimatorController 选择区域
|
||||
EditorGUILayout.LabelField("AnimatorController", EditorStyles.boldLabel);
|
||||
_selectedController = (AnimatorController)EditorGUILayout.ObjectField(
|
||||
"Controller", _selectedController, typeof(AnimatorController), false);
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 解析选项
|
||||
EditorGUILayout.LabelField("解析选项", EditorStyles.boldLabel);
|
||||
_includeNested = EditorGUILayout.Toggle("包含嵌套混合树", _includeNested);
|
||||
EditorGUILayout.HelpBox(
|
||||
_includeNested
|
||||
? "将提取所有层级的混合树(包括嵌套在子混合树中的混合树)"
|
||||
: "仅提取第一层混合树(不包含嵌套在子混合树中的混合树)",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 解析按钮
|
||||
EditorGUI.BeginDisabledGroup(_selectedController == null);
|
||||
if (GUILayout.Button("解析混合树", GUILayout.Height(30)))
|
||||
{
|
||||
ParseBlendTree();
|
||||
}
|
||||
EditorGUI.EndDisabledGroup();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 结果显示区域
|
||||
if (_blendTreeInfos != null && _blendTreeInfos.Count > 0)
|
||||
{
|
||||
EditorGUILayout.LabelField("解析结果", EditorStyles.boldLabel);
|
||||
|
||||
// 显示统计信息
|
||||
EditorGUILayout.HelpBox(
|
||||
$"共找到 {_blendTreeInfos.Count} 个混合树",
|
||||
MessageType.Info);
|
||||
|
||||
// 可滚动的文本区域
|
||||
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true));
|
||||
EditorGUILayout.TextArea(_resultText, GUILayout.ExpandHeight(true));
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 保存 JSON 按钮
|
||||
if (GUILayout.Button("保存为 JSON", GUILayout.Height(30)))
|
||||
{
|
||||
SaveToJson();
|
||||
}
|
||||
}
|
||||
else if (_blendTreeInfos != null && _blendTreeInfos.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"未找到混合树",
|
||||
MessageType.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseBlendTree()
|
||||
{
|
||||
if (_selectedController == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog("错误", "请先选择 AnimatorController", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 解析默认 State 名称(默认使用第 0 层的默认状态)
|
||||
if (_selectedController.layers != null && _selectedController.layers.Length > 0)
|
||||
{
|
||||
var baseLayer = _selectedController.layers[0];
|
||||
_defaultStateName = baseLayer.stateMachine?.defaultState != null
|
||||
? baseLayer.stateMachine.defaultState.name
|
||||
: string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
_defaultStateName = string.Empty;
|
||||
}
|
||||
|
||||
// 调用解析方法,传入 includeNested 选项
|
||||
_blendTreeInfos = AnimatorKit.ParseBlendTree(_selectedController, _includeNested);
|
||||
|
||||
// 格式化结果显示文本
|
||||
FormatResultText();
|
||||
|
||||
Debug.Log(
|
||||
$"解析完成: 找到 {_blendTreeInfos.Count} 个混合树 (包含嵌套: {_includeNested}),默认 State: {_defaultStateName}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
EditorUtility.DisplayDialog("解析失败", e.Message, "确定");
|
||||
Debug.LogError($"解析失败: {e}");
|
||||
_blendTreeInfos = null;
|
||||
_resultText = "";
|
||||
}
|
||||
}
|
||||
|
||||
private void FormatResultText()
|
||||
{
|
||||
if (_blendTreeInfos == null || _blendTreeInfos.Count == 0)
|
||||
{
|
||||
_resultText = "未找到混合树";
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = new List<string>();
|
||||
lines.Add($"=== 控制器 {_selectedController.name} 的混合树信息 ===");
|
||||
lines.Add($"默认 State: {_defaultStateName}");
|
||||
lines.Add($"共找到 {_blendTreeInfos.Count} 个混合树\n");
|
||||
|
||||
for (int i = 0; i < _blendTreeInfos.Count; i++)
|
||||
{
|
||||
var info = _blendTreeInfos[i];
|
||||
lines.Add($"[{i + 1}] 混合树名称: {info.name}");
|
||||
lines.Add($" 类型: {info.type}");
|
||||
|
||||
if (info.type == "1D")
|
||||
{
|
||||
lines.Add($" 动画片段数量: {info.clipCount}");
|
||||
lines.Add($" 混合参数: {info.blendParameter}");
|
||||
lines.Add($" 参数范围: [{info.minThreshold}, {info.maxThreshold}]");
|
||||
lines.Add($" 动画片段列表 ({info.clips?.Count ?? 0} 个):");
|
||||
if (info.clips != null)
|
||||
{
|
||||
for (int j = 0; j < info.clips.Count; j++)
|
||||
{
|
||||
var clip = info.clips[j];
|
||||
lines.Add($" [{j + 1}] {clip.clipName} (Threshold: {clip.threshold})");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (info.type == "2D")
|
||||
{
|
||||
lines.Add($" 混合参数 X: {info.blendParameter}");
|
||||
lines.Add($" 混合参数 Y: {info.blendParameterY}");
|
||||
lines.Add($" 参数范围: [{info.minThreshold}, {info.maxThreshold}]");
|
||||
lines.Add($" 动画片段列表 ({info.clips?.Count ?? 0} 个):");
|
||||
if (info.clips != null)
|
||||
{
|
||||
for (int j = 0; j < info.clips.Count; j++)
|
||||
{
|
||||
var clip = info.clips[j];
|
||||
lines.Add($" [{j + 1}] {clip.clipName} (位置: {clip.position})");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($" 动画片段数量: {info.clipCount}");
|
||||
}
|
||||
|
||||
if (i < _blendTreeInfos.Count - 1)
|
||||
{
|
||||
lines.Add("");
|
||||
}
|
||||
}
|
||||
|
||||
_resultText = string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private void SaveToJson()
|
||||
{
|
||||
if (_selectedController == null || _blendTreeInfos == null || _blendTreeInfos.Count == 0)
|
||||
{
|
||||
EditorUtility.DisplayDialog("错误", "没有可保存的数据", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 获取 AnimatorController 的资产路径
|
||||
string controllerPath = AssetDatabase.GetAssetPath(_selectedController);
|
||||
if (string.IsNullOrEmpty(controllerPath))
|
||||
{
|
||||
EditorUtility.DisplayDialog("错误", "无法获取 AnimatorController 的路径", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成 JSON 文件路径(与 .controller 文件同名同路径,扩展名为 .json)
|
||||
string directory = Path.GetDirectoryName(controllerPath);
|
||||
string fileName = Path.GetFileNameWithoutExtension(controllerPath);
|
||||
string jsonPath = Path.Combine(directory, $"{fileName}.json");
|
||||
|
||||
// 转换为 Unity 资源路径格式
|
||||
jsonPath = jsonPath.Replace("\\", "/");
|
||||
if (!jsonPath.StartsWith("Assets/"))
|
||||
{
|
||||
// 如果不是 Assets 路径,尝试转换
|
||||
string fullPath = Path.GetFullPath(jsonPath);
|
||||
string assetsPath = Path.GetFullPath(Application.dataPath);
|
||||
if (fullPath.StartsWith(assetsPath))
|
||||
{
|
||||
jsonPath = "Assets" + fullPath.Substring(assetsPath.Length).Replace("\\", "/");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("错误", "JSON 文件路径必须在 Assets 目录下", "确定");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 配置序列化设置,忽略循环引用
|
||||
var settings = new JsonSerializerSettings
|
||||
{
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
Formatting = Formatting.Indented
|
||||
};
|
||||
|
||||
// 组装配置对象(包含默认 State 名称和混合树列表)
|
||||
var config = new BlendTreeConfig
|
||||
{
|
||||
defaultStateName = _defaultStateName,
|
||||
blendTrees = _blendTreeInfos
|
||||
};
|
||||
|
||||
// 序列化为 JSON
|
||||
string jsonContent = JsonConvert.SerializeObject(config, settings);
|
||||
|
||||
// 写入文件(如果已存在则覆盖)
|
||||
File.WriteAllText(jsonPath, jsonContent, System.Text.Encoding.UTF8);
|
||||
|
||||
// 刷新资源数据库
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
EditorUtility.DisplayDialog("保存成功", $"JSON 文件已保存到:\n{jsonPath}", "确定");
|
||||
Debug.Log($"JSON 文件已保存: {jsonPath}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
EditorUtility.DisplayDialog("保存失败", e.Message, "确定");
|
||||
Debug.LogError($"保存 JSON 失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7376797afa65fb549b5347e5d570dc46
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AibisDream.Editor;
|
||||
using AibisDream.Utility;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
@@ -40,7 +41,7 @@ namespace AibisDream.SystemEditor
|
||||
private Dictionary<string, bool> _expandedNodes = new Dictionary<string, bool>();
|
||||
private bool _showDeleteButton = false; // 是否显示JSON编辑器中的删除按钮(默认隐藏)
|
||||
|
||||
[MenuItem("Tools/JSON编辑器 (Json Editor)")]
|
||||
[MenuItem(AibisEditorMenus.JsonEditor)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
GetWindow<JsonEditWindow>("JSON Editor");
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AibisDream.Editor;
|
||||
using UnityEditor;
|
||||
using UnityEditorInternal;
|
||||
using UnityEngine;
|
||||
@@ -10,7 +11,7 @@ namespace AibisDream.YarnLocalizationValidation.Editor
|
||||
{
|
||||
public class YarnL10nValidationWindow : EditorWindow
|
||||
{
|
||||
private const string MenuPath = "AIBIS/Yarn 本地化校验";
|
||||
private const string MenuPath = AibisEditorMenus.YarnLocalizationValidator;
|
||||
private const string WindowTitle = "Yarn Localization Validator";
|
||||
private const string FolderPathPrefKey = "AibisDream.YarnL10nValidation.FolderPath";
|
||||
private const string ScanSubdirsPrefKey = "AibisDream.YarnL10nValidation.ScanSubdirs";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AibisDream
|
||||
{
|
||||
/// <summary>
|
||||
/// Create 菜单路径(运行时 ScriptableObject 注册用,须与 Editor/AIBIS/AibisEditorMenus.Create 保持一致)。
|
||||
/// </summary>
|
||||
public static class AibisAssetMenus
|
||||
{
|
||||
public const string Root = "AIBIS";
|
||||
|
||||
public const string NarrativeTalkScene = Root + "/叙事/对话场景";
|
||||
public const string FrameAnimationGraph = Root + "/角色动画/帧动画 Graph";
|
||||
public const string FrameClip = Root + "/角色动画/帧动画 Clip";
|
||||
public const string BubbleStyle = Root + "/对话 UI/气泡样式";
|
||||
public const string FixCheckRule = Root + "/维修·打地鼠/检查规则";
|
||||
public const string FixCheckData = Root + "/维修·打地鼠/检查数据";
|
||||
public const string FixWhackMoleData = Root + "/维修·打地鼠/关卡数据";
|
||||
public const string HuoShanEmotionWave = Root + "/火山/情绪波配置";
|
||||
public const string HuoShanWaveform = Root + "/火山/波形配置";
|
||||
public const string BlockPuzzleValidator = Root + "/方块拼图/验证器";
|
||||
public const string MemoryPunchTapeCatalog = Root + "/记忆/打孔带目录";
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ namespace AibisDream.FixSystem
|
||||
#endif
|
||||
}
|
||||
|
||||
[CreateAssetMenu(fileName = "PunchTapeCatalog", menuName = "AibisDream/Memory/Punch Tape Catalog")]
|
||||
[CreateAssetMenu(fileName = "PunchTapeCatalog", menuName = AibisAssetMenus.MemoryPunchTapeCatalog)]
|
||||
public sealed class PunchTapeCatalog : ScriptableObject
|
||||
{
|
||||
[SerializeField] private List<PunchTapeDefinition> definitions = new();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace AibisDream.FixSystem
|
||||
{
|
||||
[CreateAssetMenu(fileName = "CheckData", menuName = "Level Data/CheckData")]
|
||||
[CreateAssetMenu(fileName = "CheckData", menuName = AibisAssetMenus.FixCheckData)]
|
||||
public class CheckData : ScriptableObject
|
||||
{
|
||||
// 目前想到的就是色彩,边数,圆角和直角
|
||||
|
||||
@@ -3,7 +3,7 @@ using UnityEngine;
|
||||
|
||||
namespace AibisDream.FixSystem
|
||||
{
|
||||
[CreateAssetMenu(fileName = "CheckRule", menuName = "Level Data/CheckRule")]
|
||||
[CreateAssetMenu(fileName = "CheckRule", menuName = AibisAssetMenus.FixCheckRule)]
|
||||
public class CheckRule : ScriptableObject
|
||||
{
|
||||
public Vector2 duration;
|
||||
|
||||
@@ -4,7 +4,7 @@ using Random = UnityEngine.Random;
|
||||
|
||||
namespace AibisDream.FixSystem
|
||||
{
|
||||
[CreateAssetMenu(fileName = "WhackMoldData", menuName = "Level Data/WhackMoldData")]
|
||||
[CreateAssetMenu(fileName = "WhackMoldData", menuName = AibisAssetMenus.FixWhackMoleData)]
|
||||
public class WhackMoleData : ScriptableObject
|
||||
{
|
||||
[Header("基本参数")]
|
||||
|
||||
@@ -3,7 +3,7 @@ using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation
|
||||
{
|
||||
[CreateAssetMenu(fileName = "FrameAnimationGraph", menuName = "AibisDream/Frame Animation/Frame Animation Graph")]
|
||||
[CreateAssetMenu(fileName = "FrameAnimationGraph", menuName = "AIBIS/角色动画/帧动画 Graph")]
|
||||
public sealed class FrameAnimationGraph : ScriptableObject
|
||||
{
|
||||
[SerializeField] private string id = string.Empty;
|
||||
|
||||
@@ -3,7 +3,7 @@ using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation
|
||||
{
|
||||
[CreateAssetMenu(fileName = "FrameClip", menuName = "AibisDream/Frame Animation/Frame Clip")]
|
||||
[CreateAssetMenu(fileName = "FrameClip", menuName = "AIBIS/角色动画/帧动画 Clip")]
|
||||
public sealed class FrameClip : ScriptableObject
|
||||
{
|
||||
[SerializeField] private string id = string.Empty;
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace AibisDream
|
||||
/// <summary>
|
||||
/// 章节类型数据
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Game Scene/TalkSceneSO")]
|
||||
[CreateAssetMenu(menuName = AibisAssetMenus.NarrativeTalkScene)]
|
||||
public class TalkSceneSO : ScriptableObject
|
||||
{
|
||||
private const string DEFAULT_EXIT_NAME = "default";
|
||||
|
||||
@@ -4,7 +4,7 @@ using UnityEngine.Events;
|
||||
|
||||
namespace AibisDream
|
||||
{
|
||||
[CreateAssetMenu(fileName = "BlockPuzzleValidator", menuName = "Block Puzzle/BlockPuzzleValidator")]
|
||||
[CreateAssetMenu(fileName = "BlockPuzzleValidator", menuName = AibisAssetMenus.BlockPuzzleValidator)]
|
||||
public class BlockPuzzleValidator : ScriptableObject
|
||||
{
|
||||
[Tooltip("基础条件, 不满足无法提交")]
|
||||
|
||||
@@ -4,10 +4,9 @@ using System.Collections.Generic;
|
||||
namespace AibisDream.MiniGame.Language
|
||||
{
|
||||
/// <summary>
|
||||
/// 分析模式关卡配置(ScriptableObject)
|
||||
/// 用于存储每个分析谜题的数据
|
||||
/// 分析模式关卡配置(ScriptableObject,已归档;不再提供 Create 菜单入口)。
|
||||
/// 用于存储每个分析谜题的数据。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "AnalysisLevel", menuName = "AIBIS/Language/Analysis Level")]
|
||||
public class AnalysisModeData : ScriptableObject
|
||||
{
|
||||
[Header("关卡基础信息")]
|
||||
|
||||
@@ -2,7 +2,7 @@ using UnityEngine;
|
||||
|
||||
namespace AibisDream.MiniGame.HuoShan.EmotionWave
|
||||
{
|
||||
[CreateAssetMenu(fileName = "EmotionWaveConfig", menuName = "AibisDream/EmotionWave Config")]
|
||||
[CreateAssetMenu(fileName = "EmotionWaveConfig", menuName = AibisAssetMenus.HuoShanEmotionWave)]
|
||||
public class EmotionWaveConfig : ScriptableObject
|
||||
{
|
||||
[System.Serializable]
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace AibisDream.MiniGame.HuoShan
|
||||
/// 波形配置
|
||||
/// 定义各个模块的波形类型和视觉参数
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "WaveformConfig", menuName = "AibisDream/Waveform/Waveform Config")]
|
||||
[CreateAssetMenu(fileName = "WaveformConfig", menuName = AibisAssetMenus.HuoShanWaveform)]
|
||||
public class WaveformConfig : ScriptableObject
|
||||
{
|
||||
[Header("情绪模块波形 - 悲伤/焦虑/恐惧")]
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// PP2 通用动画 aseprite 重导入后,PeipeiFixScene 里 UF_face(垂手)若仍指向旧子资源 ID 会丢图。
|
||||
/// 本工具改绑到「佩佩像素版-默认」aseprite 的 Frame_0。
|
||||
/// </summary>
|
||||
public static class FixPeipeiUfFaceSprite
|
||||
{
|
||||
private const string ScenePath = "Assets/Scenes/PeipeiFixScene.unity";
|
||||
// 垂手姿态用「默认」aseprite,避免依赖被 PP2 重导过的通用动画子资源 ID。
|
||||
private const string AsepritePath =
|
||||
"Assets/RawResources/Actor/\u4f69\u4f69/\u4f69\u4f69\u50cf\u7d20\u7248-\u9ed8\u8ba4.aseprite";
|
||||
private const string FaceObjectName = "\u5782\u624b (1)"; // 垂手 (1)
|
||||
|
||||
[MenuItem("AIBIS/Fix/Rebind Peipei UF Face Sprite")]
|
||||
public static void RebindFromMenu()
|
||||
{
|
||||
if (!Rebind(out var message))
|
||||
{
|
||||
EditorUtility.DisplayDialog("Rebind UF Face", message, "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
EditorUtility.DisplayDialog("Rebind UF Face", message, "OK");
|
||||
}
|
||||
|
||||
/// <summary>Unity batchmode: -executeMethod AibisDream.EditorTools.FixPeipeiUfFaceSprite.RebindBatch</summary>
|
||||
public static void RebindBatch()
|
||||
{
|
||||
if (!Rebind(out var message))
|
||||
{
|
||||
Debug.LogError("[FixPeipeiUfFaceSprite] " + message);
|
||||
EditorApplication.Exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log("[FixPeipeiUfFaceSprite] " + message);
|
||||
EditorApplication.Exit(0);
|
||||
}
|
||||
|
||||
private static bool Rebind(out string message)
|
||||
{
|
||||
var sprites = AssetDatabase.LoadAllAssetsAtPath(AsepritePath)
|
||||
.OfType<Sprite>()
|
||||
.ToArray();
|
||||
if (sprites.Length == 0)
|
||||
{
|
||||
message = "No sprites found in: " + AsepritePath;
|
||||
return false;
|
||||
}
|
||||
|
||||
var sprite = sprites.FirstOrDefault(s => s != null && s.name.Contains("Frame_0"))
|
||||
?? sprites.FirstOrDefault(s => s != null);
|
||||
if (sprite == null)
|
||||
{
|
||||
message = "Could not pick a sprite from aseprite.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var sceneAlreadyOpen = false;
|
||||
for (var i = 0; i < EditorSceneManager.sceneCount; i++)
|
||||
{
|
||||
if (EditorSceneManager.GetSceneAt(i).path == ScenePath)
|
||||
{
|
||||
sceneAlreadyOpen = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var scene = sceneAlreadyOpen
|
||||
? EditorSceneManager.GetSceneByPath(ScenePath)
|
||||
: EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Additive);
|
||||
|
||||
GameObject faceGo = null;
|
||||
foreach (var root in scene.GetRootGameObjects())
|
||||
{
|
||||
foreach (var t in root.GetComponentsInChildren<Transform>(true))
|
||||
{
|
||||
if (t.gameObject.name == FaceObjectName)
|
||||
{
|
||||
faceGo = t.gameObject;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (faceGo != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (faceGo == null)
|
||||
{
|
||||
if (!sceneAlreadyOpen)
|
||||
{
|
||||
EditorSceneManager.CloseScene(scene, true);
|
||||
}
|
||||
|
||||
message = "Face object not found: " + FaceObjectName;
|
||||
return false;
|
||||
}
|
||||
|
||||
var renderer = faceGo.GetComponent<SpriteRenderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
if (!sceneAlreadyOpen)
|
||||
{
|
||||
EditorSceneManager.CloseScene(scene, true);
|
||||
}
|
||||
|
||||
message = "SpriteRenderer missing on " + FaceObjectName;
|
||||
return false;
|
||||
}
|
||||
|
||||
Undo.RecordObject(renderer, "Rebind UF Face Sprite");
|
||||
renderer.sprite = sprite;
|
||||
EditorUtility.SetDirty(renderer);
|
||||
EditorSceneManager.MarkSceneDirty(scene);
|
||||
EditorSceneManager.SaveScene(scene);
|
||||
|
||||
if (!sceneAlreadyOpen)
|
||||
{
|
||||
EditorSceneManager.CloseScene(scene, true);
|
||||
}
|
||||
|
||||
AssetDatabase.TryGetGUIDAndLocalFileIdentifier(
|
||||
(Object)sprite, out var guid, out long localId);
|
||||
|
||||
message = string.Format(
|
||||
"Rebound {0} -> {1} (guid={2} fileID={3})",
|
||||
FaceObjectName,
|
||||
sprite.name,
|
||||
guid,
|
||||
localId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f988c46355a846d4daeb2b647a500c82
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -2,7 +2,7 @@ using UnityEngine;
|
||||
|
||||
namespace AibisDream
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Style/Bubble Style")]
|
||||
[CreateAssetMenu(menuName = AibisAssetMenus.BubbleStyle)]
|
||||
public class BubbleStyle : ScriptableObject
|
||||
{
|
||||
public Color imageColor = Color.white;
|
||||
|
||||
@@ -31,18 +31,18 @@
|
||||
| 项 | 内容 | 调用方式 |
|
||||
|---|---|---|
|
||||
| `AgentBridge.cs` | 静态薄包装 | — |
|
||||
| 场景预设 | `EnterPeipeiEyeModule()` → `EyeCue.EnterImmediate` | `Tools/Agent/Enter Peipei Eye Module` |
|
||||
| UI 操作 | `ToggleSettingPanel()`、`ToggleMainPanel()` | `Tools/Agent/Toggle Setting Panel` 等 |
|
||||
| 状态查询 | `DumpEyeOverlayState()` → 输出 layer / sortingOrder / active | `Tools/Agent/Dump Eye Overlay State` |
|
||||
| 场景预设 | `EnterPeipeiEyeModule()` → `EyeCue.EnterImmediate` | `AIBIS/Enter Peipei Eye Module` |
|
||||
| UI 操作 | `ToggleSettingPanel()`、`ToggleMainPanel()` | `AIBIS/Toggle Setting Panel` 等 |
|
||||
| 状态查询 | `DumpEyeOverlayState()` → 输出 layer / sortingOrder / active | `AIBIS/Dump Eye Overlay State` |
|
||||
| 文档 | 本文件 + 菜单清单维护 | Agent / `.cursor/rules` 引用 |
|
||||
|
||||
### 建议 Editor 菜单(第一批)
|
||||
|
||||
```
|
||||
Tools/Agent/Enter Peipei Eye Module
|
||||
Tools/Agent/Toggle Setting Panel
|
||||
Tools/Agent/Toggle Main Panel
|
||||
Tools/Agent/Dump Eye Overlay State
|
||||
AIBIS/Enter Peipei Eye Module
|
||||
AIBIS/Toggle Setting Panel
|
||||
AIBIS/Toggle Main Panel
|
||||
AIBIS/Dump Eye Overlay State
|
||||
```
|
||||
|
||||
### 代码位置(待拍板)
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
# 帧动画系统第一、二阶段手动测试方案
|
||||
|
||||
## 1. 测试目标
|
||||
|
||||
本文档用于手动验收帧动画系统前两个阶段:
|
||||
|
||||
- 第一阶段:数据模型、Graph 解析、运行时播放器、统一求值器和播放 Handle。
|
||||
- 第二阶段:Aseprite JSON 解析、ImportSource、差异预览、稳定刷新、只读 Sprite 匹配和自动切图。
|
||||
|
||||
测试重点是验证实际工作流和资产稳定性,不验收第三阶段的完整 Graph 工作台、节点画布或正式删除流程。
|
||||
|
||||
## 2. 测试前准备
|
||||
|
||||
### 2.1 环境
|
||||
|
||||
- Unity:2022.3.7f1c1。
|
||||
- 打开项目后等待脚本编译完成。
|
||||
- 清空 Console,确认没有编译错误。
|
||||
- 打开 `Window > General > Test Runner`,确认可以看到:
|
||||
- `AibisDream.FrameAnimation.Tests.EditMode`
|
||||
- `AibisDream.FrameAnimation.Tests.PlayMode`
|
||||
|
||||
### 2.2 生成测试样例
|
||||
|
||||
依次执行:
|
||||
|
||||
1. `Tools > Frame Animation > Rebuild Runtime Sample`
|
||||
2. `Tools > Frame Animation > Rebuild Import Sample`
|
||||
|
||||
生成内容:
|
||||
|
||||
- 运行时样例场景:`Assets/Scenes/FrameAnimationRuntimeTest.unity`
|
||||
- 第一阶段样例:`Assets/GameContent/Test/FrameAnimation/`
|
||||
- 第二阶段样例:`Assets/GameContent/Test/FrameAnimation/Import/`
|
||||
|
||||
第二阶段测试中可以修改 Import 文件夹内的测试 JSON。测试结束后再次执行 `Rebuild Import Sample` 即可恢复,不要修改生产动画资源。
|
||||
|
||||
### 2.3 测试记录
|
||||
|
||||
每个用例记录:
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 结果 | 通过 / 失败 / 阻塞 |
|
||||
| 实际表现 | 简述观察结果 |
|
||||
| Console | 是否出现 Error/Exception |
|
||||
| 证据 | 截图、录屏或相关资产路径 |
|
||||
| 缺陷 | 可复现步骤和预期/实际差异 |
|
||||
|
||||
## 3. 第一阶段:运行时播放测试
|
||||
|
||||
### RT-01 样例场景基础播放(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 打开 `FrameAnimationRuntimeTest.unity`。
|
||||
2. 进入 Play Mode。
|
||||
3. 同时观察左侧 SpriteRenderer 和右侧 UI Image。
|
||||
4. 选中两个对象,在 `FrameAnimationPlayer` Inspector 中观察 Runtime State。
|
||||
|
||||
预期:
|
||||
|
||||
- 左侧 `Flow Sample - SpriteRenderer` 立即显示 Intro 第 0 帧。
|
||||
- 左侧约 1.25 秒后从 Intro 自动进入 Idle,之后持续循环 Idle。
|
||||
- 左侧 Playable 始终为 `IntroToIdle`,Clip 从 `Intro` 变为 `Idle`。
|
||||
- 右侧 `Direct Idle - Image` 立即显示 Idle 第 0 帧并持续循环。
|
||||
- 右侧 Playable 和 Clip 均为 `Idle`,Node 为空。
|
||||
- 两个目标均不依赖 Animator 或 AnimatorController。
|
||||
- Console 没有 Error 或 Exception。
|
||||
|
||||
### RT-02 长时间循环稳定性(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 保持样例场景运行至少 60 秒。
|
||||
2. 观察两个目标和 Inspector 中的帧索引。
|
||||
|
||||
预期:
|
||||
|
||||
- Idle 每约 4 秒循环一次。
|
||||
- 不出现停播、闪空、越界帧或明显累计漂移。
|
||||
- 内存和 Console 不持续产生异常或日志刷屏。
|
||||
|
||||
### RT-03 动态速度(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. Play Mode 中将 Player 的 Speed 改为 `0`。
|
||||
2. 等待数秒,再改为 `0.5`、`2`,最后恢复 `1`。
|
||||
|
||||
预期:
|
||||
|
||||
- Speed 为 `0` 时停在当前帧,但 State 仍为 Playing。
|
||||
- 恢复正数后从当前进度继续,不从头重播。
|
||||
- `0.5` 明显变慢,`2` 明显变快。
|
||||
- 不丢失 Flow 节点或当前 playable。
|
||||
|
||||
### RT-04 Pause/Resume、Stop 和 Handle 语义(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 在 Test Runner 中运行第一阶段 EditMode 测试。
|
||||
2. 重点查看 PlaybackSession、PlaybackHandle、Stop、Pause/Resume 和替换请求相关用例。
|
||||
|
||||
预期:
|
||||
|
||||
- Pause 不改变当前帧,Resume 从原进度继续。
|
||||
- 新的合法 Play 将旧请求以 Replaced 结算。
|
||||
- Stop 将活动请求以 Stopped 结算。
|
||||
- 正常播完以 Completed 结算。
|
||||
- 无效请求以 Failed 结算,且不会打断正在播放的合法请求。
|
||||
- Task、协程和回调观察到同一个结果,每个请求只结算一次。
|
||||
|
||||
说明:当前样例 Inspector 只展示状态,没有完整运行时控制面板,因此这一组 API 语义以 Test Runner 的可重复结果作为验收依据。
|
||||
|
||||
### RT-05 Disable/Enable 生命周期(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. Play Mode 中等待动画进入非首帧。
|
||||
2. 禁用 `FrameAnimationPlayer` 组件。
|
||||
3. 观察显示目标和 Runtime State。
|
||||
4. 重新启用 Player。
|
||||
5. 再对整个 GameObject 执行一次禁用和启用。
|
||||
|
||||
预期:
|
||||
|
||||
- 禁用时活动请求以 Stopped 结束。
|
||||
- 当前 Sprite 保留,不自动 Clear 或 Hide。
|
||||
- 重新启用时,因为 `playOnEnable = true`,默认 playable 从头播放。
|
||||
- 左侧重新从 Intro 第 0 帧开始,右侧重新从 Idle 第 0 帧开始。
|
||||
|
||||
### RT-06 目标配置校验(P0)
|
||||
|
||||
在临时场景或复制对象上执行,测试后不要保存改动。
|
||||
|
||||
| 配置 | 预期 |
|
||||
|---|---|
|
||||
| Player,无 SpriteRenderer/Image | Inspector 显示缺失目标错误,Play 返回 Failed |
|
||||
| Player + SpriteRenderer + Image | Inspector 显示目标冲突,Play 返回 Failed |
|
||||
| Player + 唯一目标,但 Graph 为空 | Inspector 显示 Graph 缺失,Play 返回 Failed |
|
||||
| Speed 为负数 | Inspector 显示非法速度;运行时 SetSpeed 不接受负值 |
|
||||
|
||||
失败请求不得清空或替换其他对象上正在播放的合法动画。
|
||||
|
||||
### RT-07 Clear 与 HideTarget(P1)
|
||||
|
||||
操作:
|
||||
|
||||
1. 在 PlayMode Test Runner 中运行 Clear/HideTarget 相关用例。
|
||||
2. 检查 SpriteRenderer 和 Image 两套目标的结果。
|
||||
|
||||
预期:
|
||||
|
||||
- Clear:Sprite 变为 null,但显示组件仍启用。
|
||||
- HideTarget:只禁用 SpriteRenderer 或 Image。
|
||||
- Hide 后再次成功 Play 会重新启用目标,并立即显示第 0 帧。
|
||||
- SpriteRenderer 与 Image 语义一致。
|
||||
|
||||
## 4. 第二阶段:Aseprite 导入测试
|
||||
|
||||
### IMP-01 样例与中文数据(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 执行 `Rebuild Import Sample`。
|
||||
2. 选中 `ImportSampleGraph.asset`。
|
||||
3. 展开 Import Sources。
|
||||
4. 点击 `Preview All Enabled`。
|
||||
|
||||
预期:
|
||||
|
||||
- 存在 Object/只读切图和 Array/自动切图两个来源。
|
||||
- internalId 是只读 GUID,并可复制。
|
||||
- 中文来源名、Tag 和 frameName 没有乱码。
|
||||
- 可看到 `待机`、`眨眼`、`转身`、`惊讶` 等 Imported Clip。
|
||||
- 初始预览不出现 Error;成功构建后 Clip 状态应为 Unchanged。
|
||||
- Imported Clip 显示为 Graph 的 sub-asset,而不是独立 `.asset`。
|
||||
|
||||
### IMP-02 Object/Array 与 direction(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 在 Test Runner 中运行第二阶段 Parser 和 TagExpansion 测试。
|
||||
2. 查看 Object、Array 和四种 direction 用例。
|
||||
|
||||
预期:
|
||||
|
||||
- Object 和 Array 得到相同语义的 SourceFrame。
|
||||
- Object 帧顺序保持 JSON 属性原始顺序,不按名称重新排序。
|
||||
- `forward`、`reverse`、`pingpong`、`pingpong_reverse` 顺序正确。
|
||||
- pingpong 不重复首尾端点,单帧 Tag 只产生一帧。
|
||||
|
||||
### IMP-03 普通内容更新与原地刷新(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 在 Project 窗口展开 `ImportSampleGraph`,选中任意 Imported Clip并保持 Inspector 锁定。
|
||||
2. 打开对应测试 JSON,将该 Tag 使用帧的 `duration` 改为另一个正整数。
|
||||
3. 回到 Unity,等待 JSON 重新导入。
|
||||
4. 点击对应来源的 `Preview Source`。
|
||||
5. 确认显示 SourceChanged Warning 和 Updated Clip。
|
||||
6. 点击 `Refresh Source`。
|
||||
|
||||
预期:
|
||||
|
||||
- Preview 不直接修改 Clip。
|
||||
- Refresh 后仍是原来的 Clip sub-asset,锁定的引用不丢失。
|
||||
- Clip 的帧时长更新。
|
||||
- `displayName`、`speed` 和 `defaultEndBehavior` 不被覆盖。
|
||||
- 刷新后再次 Preview,状态变为 Unchanged,来源变化 Warning 消失。
|
||||
|
||||
### IMP-04 用户重命名 Clip id 后刷新(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 选择一个未作为默认 playable 的 Imported Clip。
|
||||
2. 将 Clip `id`、`displayName` 和 `speed` 改为自定义值。
|
||||
3. 修改其源 JSON 的 duration。
|
||||
4. 对来源执行 Preview 和 Refresh。
|
||||
|
||||
预期:
|
||||
|
||||
- 刷新仍通过 `importSourceId + sourceTagName` 找到原 Clip。
|
||||
- frames 被更新。
|
||||
- 用户设置的 id、displayName、speed 和结束行为保持不变。
|
||||
- 不会因为 Clip id 已改变而创建重复 Clip。
|
||||
|
||||
测试完成后执行 `Rebuild Import Sample` 恢复样例,避免重命名影响默认 playable 或 Flow 引用。
|
||||
|
||||
### IMP-05 Tag Missing 与恢复(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 从 Object 测试 JSON 的 `frameTags` 中暂时删除 `待机` 条目,不删除 frames。
|
||||
2. 点击 `Preview Source`。
|
||||
3. 确认 `待机` 显示 Missing,再执行 Refresh。
|
||||
4. 检查 Graph sub-assets。
|
||||
5. 将原 Tag 完整恢复,再次 Preview 和 Refresh。
|
||||
|
||||
预期:
|
||||
|
||||
- Tag 消失时原 Clip 保留,只设置 `isMissingFromSource = true`。
|
||||
- 原 Clip 的 Sprite 和帧表不被自动删除。
|
||||
- Missing Clip 在完整 Graph 校验中报告错误。
|
||||
- Tag 恢复后更新同一个 Clip,并清除 Missing。
|
||||
- Node、Flow 或其他对象对原 Clip 的引用不丢失。
|
||||
|
||||
### IMP-06 Tag 改名规则(P1)
|
||||
|
||||
操作:
|
||||
|
||||
1. 将测试 JSON 中一个 Tag 改为全新的、不冲突的名称。
|
||||
2. Preview 并 Refresh。
|
||||
|
||||
预期:
|
||||
|
||||
- 旧 Tag 对应 Clip 变为 Missing。
|
||||
- 新 Tag 创建新的 Imported Clip sub-asset。
|
||||
- 系统不猜测两者关联,也不迁移旧引用。
|
||||
|
||||
执行 `Rebuild Import Sample` 恢复样例。
|
||||
|
||||
### IMP-07 只读 Sprite 匹配(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 选择 Object/只读切图来源,确认 `manageSpriteSlicing = false`。
|
||||
2. 记录对应 PNG `.meta` 的 Git diff 状态。
|
||||
3. Preview 和 Refresh 一次未变化的来源。
|
||||
4. 将 JSON 中一个 frame rect 改成无法匹配已有 Sprite 的位置,再 Preview。
|
||||
|
||||
预期:
|
||||
|
||||
- 正常情况下按 frameName 和 rect 唯一匹配现有 Sprite。
|
||||
- 未变化刷新不会修改 TextureImporter 或 PNG `.meta`。
|
||||
- rect 无法匹配时出现 SpriteMatchFailed Error。
|
||||
- 系统不会自动改切图,也不会应用 Graph/Clip 修改。
|
||||
|
||||
### IMP-08 自动切图与稳定 Sprite ID(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 选择 Array/自动切图来源,确认 `manageSpriteSlicing = true`。
|
||||
2. 将某个 Sprite 拖到临时场景中的 SpriteRenderer,形成真实序列化引用。
|
||||
3. 修改 JSON 中该 frame 的 rect,但保持 frameName 不变且 rect 合法。
|
||||
4. 点击 `Preview Source`。
|
||||
5. 检查 Sprite diff 后点击 `Refresh Source`,确认 TextureImporter 修改对话框。
|
||||
|
||||
预期:
|
||||
|
||||
- 差异明确显示 Updated SpriteRect。
|
||||
- 必须确认后才修改 TextureImporter。
|
||||
- 刷新后同名 Sprite 保持原 spriteID。
|
||||
- 临时 SpriteRenderer 的 Sprite 引用不变,不出现 Missing。
|
||||
- rect/pivot 更新为 JSON 和 ImportSource 设置。
|
||||
- JSON 中消失的 frameName 对应旧 SpriteRect仍保留。
|
||||
|
||||
### IMP-08A 合并帧共享 Sprite(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 使用包含多个 SourceFrame 指向相同 `frame.x/y/w/h` 的 Aseprite JSON。
|
||||
2. 确认来源设置为 `manageSpriteSlicing = true`,点击 `Preview Source`。
|
||||
3. 检查来源摘要中的逻辑帧、SpriteSlot 和共享别名数量。
|
||||
4. 确认刷新后检查对应 Imported Clip 的帧表和 Sprite引用。
|
||||
5. 再次执行 Preview 和 Refresh。
|
||||
|
||||
佩佩素材的预期统计:
|
||||
|
||||
- 347 个逻辑 SourceFrame。
|
||||
- 205 个唯一 SpriteSlot。
|
||||
- 142 个共享别名。
|
||||
|
||||
预期:
|
||||
|
||||
- 相同 rect 只创建一个有效 SpriteRect。
|
||||
- 不同 SourceFrame仍保留各自的 frameName、sourceIndex、duration 和 Tag顺序。
|
||||
- 指向同一 rect 的 Clip Frame直接引用同一个 Unity Sprite。
|
||||
- 第二次 Preview 中 SpriteSlot 和 Clip均显示 Unchanged,Sprite ID保持稳定。
|
||||
- 原始 PNG 和 JSON不被修改。
|
||||
|
||||
### IMP-09 多写入所有者冲突(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 在样例 Graph 中点击 `Add ImportSource`。
|
||||
2. 启用新来源,绑定自动切图来源正在使用的同一 Texture 和任意有效 JSON。
|
||||
3. 设置 `manageSpriteSlicing = true`。
|
||||
4. 点击 `Preview All Enabled`。
|
||||
|
||||
预期:
|
||||
|
||||
- 出现 TextureOwnershipConflict Error,并列出冲突来源。
|
||||
- Refresh 不修改 TextureImporter、Graph 或 Clip。
|
||||
- 将新增来源禁用后冲突消失。
|
||||
|
||||
测试完成后执行 `Rebuild Import Sample` 清理额外来源。
|
||||
|
||||
### IMP-10 阻断错误与单来源原子性(P0)
|
||||
|
||||
依次测试以下任一错误:
|
||||
|
||||
- duration 改为 `0`。
|
||||
- frame rect 越出 Texture。
|
||||
- `trimmed = true`。
|
||||
- `rotated = true`。
|
||||
- direction 改为未知值。
|
||||
- Tag 范围越界。
|
||||
|
||||
操作:
|
||||
|
||||
1. 先记录当前 Clip 帧表和 Texture `.meta`。
|
||||
2. 制造错误并点击 `Preview Source`。
|
||||
3. 再点击 `Refresh Source`。
|
||||
|
||||
预期:
|
||||
|
||||
- Preview 显示具体结构化 Error。
|
||||
- 不出现部分 Clip 更新。
|
||||
- TextureImporter 和 `.meta` 不变化。
|
||||
- `lastSourceHash` 不更新。
|
||||
- 现有 Clip 和 sub-asset 引用保持原样。
|
||||
|
||||
### IMP-11 全部来源原子性(P0)
|
||||
|
||||
操作:
|
||||
|
||||
1. 在一个来源中修改合法 duration,使其产生 Updated。
|
||||
2. 在另一个来源中制造 duration 为 `0` 的阻断错误。
|
||||
3. 点击 `Preview All Enabled`,然后点击 `Refresh All Enabled`。
|
||||
|
||||
预期:
|
||||
|
||||
- 预览同时显示合法变化和阻断错误。
|
||||
- 因任一启用来源失败,整批刷新不应用。
|
||||
- 合法来源的 Clip 也保持刷新前状态。
|
||||
- 两个来源的 hash 均不更新。
|
||||
|
||||
### IMP-12 命名冲突(P1)
|
||||
|
||||
分别制造:
|
||||
|
||||
- 两个启用 ImportSource 使用同名 Tag。
|
||||
- 新 Tag 与现有 Clip id 同名。
|
||||
- 新 Tag 与现有 Flow id 同名。
|
||||
|
||||
预期:
|
||||
|
||||
- Preview 显示 TagConflict 或 PlayableIdConflict。
|
||||
- 错误信息能够指出来源和冲突名称。
|
||||
- 系统不自动加前缀、不自动改名、不创建部分 Clip。
|
||||
|
||||
### IMP-13 Undo/Redo 与保存稳定性(P1)
|
||||
|
||||
操作:
|
||||
|
||||
1. 执行一次成功的 duration 刷新。
|
||||
2. 使用 Undo,检查 Clip 帧表和来源 hash。
|
||||
3. 使用 Redo,再次检查。
|
||||
4. 保存项目,关闭并重新打开 Unity。
|
||||
5. 重新检查 Graph、Imported Clip、ImportInfo 和中文名称。
|
||||
|
||||
预期:
|
||||
|
||||
- Undo/Redo 对 Graph 和 Clip 修改成组生效。
|
||||
- 新建 sub-asset 不留下孤立对象。
|
||||
- 重启后中文名称、sourceTagName、internalId 和引用不变化。
|
||||
- 再次 Preview 能正确判断当前来源是否已刷新。
|
||||
|
||||
## 5. 自动测试回归
|
||||
|
||||
手动测试完成前至少执行:
|
||||
|
||||
1. Test Runner > EditMode > Run All。
|
||||
2. Test Runner > PlayMode > Run All。
|
||||
|
||||
通过标准:
|
||||
|
||||
- 第一阶段既有测试全部通过。
|
||||
- 第二阶段 Parser、direction、只读匹配、稳定 sub-asset、Missing 和所有权测试全部通过。
|
||||
- 没有新增 Console Error 或未处理 Exception。
|
||||
|
||||
## 6. 总体验收标准
|
||||
|
||||
以下项目全部满足后,第一、二阶段可视为通过:
|
||||
|
||||
- 不依赖 AnimatorController 即可播放直接 Clip 和线性 Flow。
|
||||
- SpriteRenderer 与 Image 的首帧、循环、速度、生命周期和显示语义正确。
|
||||
- 播放请求完成原因和 Failed 隔离符合定义。
|
||||
- Object/Array Aseprite JSON 均能正确解析,中文数据稳定。
|
||||
- Imported Clip 能新增、原地更新、Missing 和恢复。
|
||||
- 用户拥有字段不会被刷新覆盖。
|
||||
- 只读来源绝不修改 TextureImporter。
|
||||
- 自动切图为同名 frameName 保留稳定 Sprite ID。
|
||||
- 合并帧只创建唯一 SpriteSlot,同时保留全部逻辑帧时长与播放顺序。
|
||||
- 多写入所有者、素材错误和命名冲突能够阻断刷新。
|
||||
- 单来源和全部来源刷新都不产生部分 Graph/Clip 更新。
|
||||
- 保存、Undo/Redo 和重新打开 Unity 后资产引用保持稳定。
|
||||
|
||||
若任一 P0 用例失败,先停止进入第三阶段并记录缺陷;P1 问题可以评估后决定是否阻断,但不得破坏资产引用、原子刷新或运行时播放语义。
|
||||
@@ -1,177 +0,0 @@
|
||||
# 帧动画系统第三阶段手动测试
|
||||
|
||||
## 1. 测试目标
|
||||
|
||||
验证 `FrameAnimationGraph Editor` 的资源浏览、Manual Clip 编辑、正式重命名、安全删除、ImportSource 集成、校验定位、Undo/Redo 和本机工作区状态。
|
||||
|
||||
本阶段中央区域仍是画布占位区,不测试节点创建、连线、Flow 创建或动画预览。
|
||||
|
||||
## 2. 测试前准备
|
||||
|
||||
1. 使用 Unity 2022.3.7f1c1 打开项目,等待脚本和资产导入完成。
|
||||
2. 备份需要保留的测试 Graph;删除 sub-asset 和刷新 Imported Clip 都会修改资产。
|
||||
3. 可使用现有 `ImportSampleGraph`,也可以通过 `Tools > Frame Animation > Rebuild Import Sample` 重建标准样例。
|
||||
4. 若重建样例,确认 `Assets/GameContent/Test/FrameAnimation/Import/` 下没有需要保留的手工改动。
|
||||
5. 打开 `Window > Aibis Dream > Frame Animation Graph Editor`。
|
||||
|
||||
## 3. 打开与布局
|
||||
|
||||
### 3.1 打开入口
|
||||
|
||||
分别验证以下入口会打开同一个工作台并选中正确 Graph:
|
||||
|
||||
- 菜单打开后,从顶部 ObjectField 选择 Graph。
|
||||
- 在 Project 中双击 `FrameAnimationGraph`。
|
||||
- 在 Graph Inspector 点击 `Open Frame Animation Graph Editor`。
|
||||
- 对仅被一个 Graph 引用的 FrameClip,在 Clip Inspector 点击打开按钮或双击资产。
|
||||
|
||||
预期:窗口一次只编辑一个 Graph;切换 Graph 后,前一个 Graph 的属性修改不会丢失。
|
||||
|
||||
### 3.2 布局持久化
|
||||
|
||||
1. 拖动左侧、右侧和底部面板尺寸。
|
||||
2. 切换资源标签、搜索词、筛选、排序和底部标签。
|
||||
3. 关闭并重新打开窗口,再重载脚本或重启 Unity。
|
||||
|
||||
预期:面板尺寸、折叠状态、资源标签、搜索筛选、排序、Graph 和有效选择得到恢复;不同 Graph 的状态互不覆盖。
|
||||
|
||||
## 4. 资源浏览与定位
|
||||
|
||||
### 4.1 Clip 查询
|
||||
|
||||
1. 在 Clips 中依次搜索 id、displayName、sourceTagName 和来源名称。
|
||||
2. 测试 Manual、Imported、Normal、Missing、Referenced、Unused 筛选。
|
||||
3. 测试名称、来源、帧数、时长、Missing 和引用数排序。
|
||||
4. 清空搜索,确认按 Manual 和 ImportSource 分组;输入搜索词后确认结果变为扁平列表。
|
||||
|
||||
预期:每行显示帧数、时长、来源、Missing、Node 引用数和问题标记,结果顺序稳定。
|
||||
|
||||
### 4.2 引用定位
|
||||
|
||||
1. 选择无 Node 引用的 Clip,点击 `Locate References`。
|
||||
2. 选择单引用 Clip,再次定位。
|
||||
3. 选择多引用 Clip,再次定位并从菜单选择一个 Node。
|
||||
|
||||
预期:无引用时显示提示;单引用直接定位;多引用显示选择菜单。Node 在右侧只读显示,中央占位区同步显示当前定位对象。
|
||||
|
||||
### 4.3 校验定位
|
||||
|
||||
人为制造一个安全的校验问题,例如将 Manual Frame 的 durationMs 改为 0,然后点击 `Validate`。
|
||||
|
||||
预期:Validation 出现结构化条目;点击条目会切换到正确资源标签并显示对应对象和处理建议。
|
||||
|
||||
## 5. Manual Clip
|
||||
|
||||
### 5.1 创建 Graph 内 Manual Clip
|
||||
|
||||
1. 点击 `New Manual`。
|
||||
2. 输入唯一 id 和 displayName,保持“外部独立 .asset”关闭。
|
||||
3. 创建后检查 Project 资产和 Clip 属性。
|
||||
|
||||
预期:Clip 是当前 Graph 的 sub-asset;首始帧表为空;结束行为取 Graph 的 `New Manual End Behavior`。
|
||||
|
||||
### 5.2 创建外部 Manual Clip
|
||||
|
||||
重复创建流程并勾选外部资产,保存到测试目录。
|
||||
|
||||
预期:生成独立 `.asset`,同时加入当前 Graph;取消保存对话框不会产生半成品。
|
||||
|
||||
### 5.3 添加已有外部 Clip
|
||||
|
||||
点击 `Add Existing`,分别尝试:
|
||||
|
||||
- 合法的外部 Manual Clip。
|
||||
- Imported Clip。
|
||||
- 其他 Graph 的 sub-asset Clip。
|
||||
- 当前 Graph 已引用的 Clip。
|
||||
- id 与当前 Clip 或 Flow 冲突的 Clip。
|
||||
|
||||
预期:仅第一种成功,其余给出明确原因。
|
||||
|
||||
### 5.4 编辑帧表
|
||||
|
||||
对 Manual Clip 执行新增、复制、删除和拖拽排序,并编辑 Sprite 与 durationMs。
|
||||
|
||||
预期:新帧默认为 Sprite 空、durationMs 100、frameName 空、sourceIndex -1;空 Sprite 合法;frameName/sourceIndex 只读。Undo/Redo 能恢复每一步。
|
||||
|
||||
### 5.5 Imported 转 Manual
|
||||
|
||||
选择 Imported Clip,点击 `Copy As Manual`,分别测试 Graph sub-asset 和外部资产。
|
||||
|
||||
预期:帧表和用户字段被复制,Sprite 引用复用;新 Clip 的 ImportInfo 为空,之后刷新来源不会修改它。Imported Clip 原帧表始终只读。
|
||||
|
||||
## 6. 正式重命名
|
||||
|
||||
### 6.1 Clip ID
|
||||
|
||||
1. 选择被 Node 和 defaultPlayableId 引用的 Clip。
|
||||
2. 点击 `Rename Clip ID`,观察警告和受影响引用数。
|
||||
3. 尝试空 id、与 Clip 冲突、与 Flow 冲突和合法 id。
|
||||
4. 成功后执行 Undo/Redo。
|
||||
|
||||
预期:普通属性区不能直接编辑 id;合法重命名会原子更新 Clip、全部 Node.clipId 和 defaultPlayableId。Undo/Redo 不产生一半新一半旧的状态。
|
||||
|
||||
对被两个 Graph 共享的外部 Manual Clip 重复测试。
|
||||
|
||||
预期:禁止重命名,并列出引用它的 Graph。
|
||||
|
||||
### 6.2 Flow 与 Graph ID
|
||||
|
||||
分别通过正式按钮重命名 Flow 和 Graph。
|
||||
|
||||
预期:Flow 同步更新 defaultPlayableId 和 FlowEditorData;Graph 只更新自身 id。两个对话框均明确提示外部字符串契约无法自动迁移。
|
||||
|
||||
## 7. 删除与所有权
|
||||
|
||||
### 7.1 Clip
|
||||
|
||||
依次测试:
|
||||
|
||||
- 被 Node 引用的 Clip。
|
||||
- 被 defaultPlayableId 引用的 Clip。
|
||||
- 未引用的外部 Manual Clip。
|
||||
- 未引用的 Graph sub-asset Manual Clip。
|
||||
- 未引用且非 Missing 的 Imported Clip。
|
||||
|
||||
预期:前两种在确认前被阻止并可据提示定位;外部 Clip 仅移除当前 Graph 引用且 `.asset` 保留;sub-asset 从 Graph 和资产文件中删除;非 Missing Imported Clip 会提示下次刷新可能重建。
|
||||
|
||||
### 7.2 Flow
|
||||
|
||||
测试删除默认 Flow 和非默认 Flow。
|
||||
|
||||
预期:默认 Flow 被阻止;非默认 Flow 仅删除 Flow 定义,不删除 Node 或 Edge。
|
||||
|
||||
### 7.3 ImportSource
|
||||
|
||||
1. 删除仍有关联 Imported Clip 的来源。
|
||||
2. 将关联 Clip 删除或复制为 Manual 后再次删除来源。
|
||||
|
||||
预期:有关联 Clip 时阻止并列出 Clip;允许删除时不会删除 Texture、JSON、Sprite 或 SpriteRect。
|
||||
|
||||
## 8. 导入与差异
|
||||
|
||||
1. 在 Sources 中编辑 displayName、启用状态、Texture、JSON、pivot、切图模式和新 Clip 默认结束行为。
|
||||
2. 检查 internalId 只读且可复制。
|
||||
3. 分别执行单来源和全部来源 Preview、Refresh。
|
||||
4. 对自动切图来源制造 SpriteRect 变化并拒绝确认,再重新刷新并确认。
|
||||
5. 制造非法 JSON 后 Preview/Refresh。
|
||||
|
||||
预期:预览不修改资产;差异按 Added、Updated、Missing、Unchanged、Error 分组并可定位;切图变化必须确认;失败保留完整差异和错误,Graph、Clip、TextureImporter 不出现部分更新。
|
||||
|
||||
## 9. 保存、Undo 与异常恢复
|
||||
|
||||
1. 连续执行创建、帧编辑、重命名、删除和 Source 修改。
|
||||
2. 逐步 Undo,再逐步 Redo。
|
||||
3. 点击 Save,关闭 Unity 后重开项目。
|
||||
4. 删除当前窗口正在编辑的测试 Graph。
|
||||
|
||||
预期:每个资产操作以具名事务恢复;列表、引用数和校验随 Undo/Redo 重建;保存和 Domain Reload 后数据稳定;资产删除后窗口自动清理失效 Graph 和选择,不抛出持续异常。
|
||||
|
||||
## 10. 通过标准
|
||||
|
||||
- 工作台所有入口、布局恢复和统一定位正常。
|
||||
- Manual、Imported、外部资产和 Graph sub-asset 的所有权行为符合预期。
|
||||
- id 只能正式重命名,引用迁移原子且可撤销。
|
||||
- 删除不会留下悬空引用,也不会误删外部资产或导入源资源。
|
||||
- Import Preview/Refresh、差异和综合校验能在同一窗口完成并准确定位。
|
||||
- Console 无持续异常;重新打开项目后资产内容和引用保持稳定。
|
||||
@@ -1,79 +0,0 @@
|
||||
# 帧动画系统第五阶段手动测试
|
||||
|
||||
## 1. 测试准备
|
||||
|
||||
1. 在 Unity 执行 `Tools > Frame Animation > Create Phase 5 Preview Sample`。
|
||||
2. 打开 `Assets/GameContent/Test/FrameAnimation/Preview/PreviewSampleGraph.asset`。
|
||||
3. 确认通过 Inspector 的 `Open Graph Editor` 进入工作台。
|
||||
4. 首次测试前将 Node Preview Policy 设为 `SelectedOnly`、速度设为 `1×`、背景设为 `Checkerboard`。
|
||||
|
||||
样例构建器只在资产不存在时创建,不会覆盖已经存在的 Preview Sample 或其他测试 Graph。
|
||||
|
||||
## 2. Clip 独立预览
|
||||
|
||||
依次选中 `PreviewAction`、`PreviewEmpty` 和 `PreviewIdle`。
|
||||
|
||||
- 右侧显示 Clip Preview,顶部与右侧的 Play/Pause、Stop、Restart、Previous、Next 和时间轴保持同步。
|
||||
- Pause 保留当前帧;Stop 回到实际第 0 帧;Restart 从第 0 帧开始播放。
|
||||
- 拖动时间轴后保持 Paused,Previous/Next 定位到帧边界。
|
||||
- `PreviewEmpty` 的空帧显示棋盘格并停留约 300 ms,不跳帧。
|
||||
- `PreviewIdle` 展示一轮时间轴,总时长显示 `∞`,播放时持续循环。
|
||||
- 切换 Dark、Light、Checkerboard 后画面背景立即更新,Graph 和 Clip 不变 dirty。
|
||||
- 测试 Fit、1×、2×、3×、4×、8× 和 Manual;非 Fit 放大后可拖动画面。
|
||||
|
||||
## 3. Node 内嵌预览策略
|
||||
|
||||
1. 选择 `Finite Action` Node。
|
||||
2. 在 `SelectedOnly` 下确认只有主选中 Node 自动播放。
|
||||
3. 点击 Stop,确认该 Node 停在第 0 帧且不会立即自动重启;重新选择或点击 Play 后恢复。
|
||||
4. 切换 `Static`,确认所有 Node 显示第一个非空代表帧,选中 Node 仍可使用顶部控制器手动播放。
|
||||
5. 切换 `AllVisible`,确认视口内 Node 播放;平移使 Node 离开视口后停止更新,移回后重新开始。
|
||||
6. 框选多个 Node,确认只有主选中 Node成为顶部上下文目标。
|
||||
|
||||
## 4. 有限 Flow 与跨节点时间轴
|
||||
|
||||
1. 在 Flows 选择 `PreviewFiniteFlow` 并播放。
|
||||
2. 确认画布当前 Node、已经过路径和后续路径状态不同。
|
||||
3. 播放跨过 `Finite Action -> Finite Empty` 时,当前 Node 和 Clip 信息随之切换。
|
||||
4. 拖动顶部时间轴跨越节点边界,确认对应 Node 与内嵌 Sprite 同步定位。
|
||||
5. Previous/Next 可以跨节点逐帧。
|
||||
6. 播放自然结束后保留终点末帧;Stop 回到入口第 0 帧。
|
||||
|
||||
## 5. Loop、零速与结束行为
|
||||
|
||||
- `PreviewLoopFlow`:时间轴显示前缀加终点一轮,总时长为 `∞`;进入终点后进度只在终点循环段内循环。
|
||||
- `PreviewBlockedFlow`:显示零速度阻塞位置,播放状态保持但不推进,时间轴不能越过阻塞点。
|
||||
- `PreviewClearFlow`:自然结束后显示透明棋盘格并标记 Clear。
|
||||
- `PreviewHideFlow`:自然结束后同样显示透明内容,但状态标记为 HideTarget。
|
||||
- 将预览速度依次设为 `0.25× / 0.5× / 1× / 2× / 4×`,确认只改变播放快慢,不改变时间轴总长度。
|
||||
|
||||
## 6. Flow 预览与聚焦
|
||||
|
||||
1. 播放任意 Flow,按 Esc 或点击 Exit Focus。
|
||||
2. 确认全部节点恢复正常亮度,但 Flow 预览继续运行。
|
||||
3. 选择当前 Flow 内 Node/Edge,确认 Flow 继续播放且顶部仍显示 Flow 目标。
|
||||
4. 选择 Flow 外 Node、Graph、Clip、Source 或其他 Flow,确认当前 Flow 预览停止并切换上下文。
|
||||
5. 停止 Flow 后确认原 NodePreviewPolicy 恢复。
|
||||
|
||||
## 7. 修改、刷新与错误
|
||||
|
||||
- 播放 Clip/Node 时修改 Sprite、duration、speed 或结束行为,确认预览重建且不使用旧帧引用。
|
||||
- 播放 Flow 时修改入口、Edge 或 Node Clip,确认 Flow 安全停止,重新 Play 后使用新路径。
|
||||
- Preview/Refresh ImportSource 后确认活动预览失效并可使用刷新后的 Sprite 重新播放。
|
||||
- 空 Clip、Missing Clip、负速度和非法 Flow 显示结构化错误,不抛异常、不修改资产。
|
||||
- 预览期间观察 dirty 状态和 Undo 历史,确认单纯播放、拖动与缩放不产生资产修改。
|
||||
|
||||
## 8. 工作区恢复
|
||||
|
||||
1. 修改左右宽度、底部高度、搜索、筛选、排序、选择、画布 pan/zoom 和预览偏好。
|
||||
2. 切换到其他 Graph 再切回,确认各 Graph 状态独立恢复。
|
||||
3. 关闭并重新打开窗口,确认状态恢复。
|
||||
4. 触发脚本重编译或 Domain Reload,确认状态恢复但播放进度不恢复。
|
||||
5. 确认 Flow 聚焦始终从 Show All 开始,不恢复旧 focusedFlowId。
|
||||
|
||||
## 9. 第一版完整回归
|
||||
|
||||
- 按第一至第四阶段文档回归资产管理、导入刷新、节点编排、Flow 聚焦、Undo/Redo 和运行时播放。
|
||||
- 在 `FrameAnimationRuntimeTest.unity` 验证 SpriteRenderer 与 Image 运行时结果和编辑器预览一致。
|
||||
- 运行 EditMode、PlayMode、批处理编译和 `git diff --check`。
|
||||
- 对照 `Docs/动画系统需求整理.md` 第 9 节逐项确认第一版验收标准。
|
||||
@@ -1,189 +0,0 @@
|
||||
# 帧动画系统第四阶段手动测试
|
||||
|
||||
## 1. 测试目标
|
||||
|
||||
验证第四阶段新增的节点画布、顺序连线、Flow 编排、选择定位、删除影响分析、自动布局和状态恢复。导入刷新可按第一、二阶段文档做回归;动画预览、时间轴和逐帧播放不属于本阶段。
|
||||
|
||||
## 2. 测试准备
|
||||
|
||||
1. 备份或提交当前工作区改动。
|
||||
2. 在 Unity 中等待脚本编译完成,确认 Console 没有编译错误。
|
||||
3. 如需标准样例,执行 `Tools > Frame Animation > Upgrade Phase 4 Sample (Non-Destructive)`。
|
||||
4. 打开 `Assets/GameContent/Test/FrameAnimation/Import/ImportSampleGraph.asset`。
|
||||
5. 点击 Inspector 中的 `Open Graph Editor`,或使用 `Window > Aibis Dream > Frame Animation Graph Editor`。
|
||||
|
||||
样例升级是幂等、非重建操作:它只补充固定 ID 的三个 Node、两条 Edge、两个 Flow 及其 EditorData;不会删除或重建 Graph,不会移除已有来源或 Clip。重复执行后数量不应继续增加。
|
||||
|
||||
## 3. 基础画布
|
||||
|
||||
### 3.1 打开与导航
|
||||
|
||||
1. 用鼠标滚轮缩放画布。
|
||||
2. 按住中键拖动画布。
|
||||
3. 拖出矩形框选多个节点。
|
||||
4. 点击 `Frame Selection`。
|
||||
|
||||
预期:缩放、平移、框选正常;`Frame Selection` 将选中元素完整放入视野;多选时右侧显示数量摘要,不提供批量属性编辑。
|
||||
|
||||
### 3.2 已有数据加载
|
||||
|
||||
检查样例中的 `待机 Node`、`眨眼 Node` 和 `Shared Idle Node`,以及两条指向共享节点的 Edge。
|
||||
|
||||
预期:节点位置稳定;共享节点显示多个 Flow 标记;`Shared Idle Node` 显示 Loop 结束行为且输出端不可连接;打开窗口本身不会新增 Node 或 Edge。
|
||||
|
||||
## 4. Node 创建与编辑
|
||||
|
||||
### 4.1 从 Clip 列表拖入
|
||||
|
||||
1. 在左侧选择 `Clips`。
|
||||
2. 将同一个 Clip 分别拖到画布两个不同位置。
|
||||
|
||||
预期:创建两个不同 internalId 的 Node,二者引用同一 Clip;释放位置被保存;Undo 一次只撤销最近创建的一个 Node。
|
||||
|
||||
### 4.2 右键创建
|
||||
|
||||
1. 在画布空白处右键,选择 `Create Clip Node`。
|
||||
2. 在搜索框查找一个 Clip 并创建。
|
||||
|
||||
预期:节点出现在右键位置;displayName 默认取 Clip displayName,空时取 Clip id。
|
||||
|
||||
### 4.3 属性编辑
|
||||
|
||||
选中 Node,在右侧依次测试:
|
||||
|
||||
- 修改 displayName。
|
||||
- 从 Clip 下拉切换引用。
|
||||
- 开启速度覆盖并输入 `0`、正数、负数。
|
||||
- 开启和关闭结束行为覆盖。
|
||||
- 复制 internalId。
|
||||
|
||||
预期:修改立即反映到画布;速度 `0` 被保留,负数被保留并产生可定位校验错误;失效 Clip 显示 `<Missing: oldId>` 并可通过下拉修复;普通属性编辑支持 Undo/Redo。
|
||||
|
||||
## 5. Edge 连接约束
|
||||
|
||||
分别尝试以下操作:
|
||||
|
||||
1. 从无后继、无结束覆盖的 Node 连接到另一个 Node。
|
||||
2. 再从同一起点连接第二个后继。
|
||||
3. 节点连接自身。
|
||||
4. 连接一条会形成多节点环路的 Edge。
|
||||
5. 从设置了结束行为覆盖的 Node 拉出连接。
|
||||
|
||||
预期:第 1 项成功,Edge 固定为 `default / Always`;第 2~5 项被阻止并显示明确原因,Graph 数据不变化。
|
||||
|
||||
选中已有后继的 Node,再开启结束行为覆盖。
|
||||
|
||||
预期:出现“删除 Edge 并应用”确认;确认后 Edge 与结束行为在同一个 Undo 事务内变化;取消则两者都不变化。
|
||||
|
||||
## 6. Flow 创建与入口
|
||||
|
||||
### 6.1 创建 Flow
|
||||
|
||||
1. 只选中一个非 Flow 入口 Node。
|
||||
2. 点击画布工具栏 `Create Flow`,或节点右键 `Create Flow From Node`。
|
||||
3. 保持默认 id 创建。
|
||||
|
||||
预期:默认 id 为“节点显示名 + Flow”;冲突时追加 `2`、`3`;Clip/Flow 同名会阻止创建;已作为其他 Flow 入口的 Node 会阻止创建。
|
||||
|
||||
### 6.2 修改入口
|
||||
|
||||
1. 左侧选择一个 Flow。
|
||||
2. 在画布选择目标 Node。
|
||||
3. 回到 Flow 属性,点击 `Set Selected Node As Entry`。
|
||||
|
||||
预期:入口只通过该命令修改;目标已是其他 Flow 入口时阻止修改;成功后 Flow 聚焦和可达范围立即更新。
|
||||
|
||||
### 6.3 聚焦与共享节点
|
||||
|
||||
1. 从左侧或 Flow 下拉分别选择两个样例 Flow。
|
||||
2. 检查工具栏显示 `Focused: <Flow>` 和 `Exit Focus`,点击 `Frame Flow`。
|
||||
3. 选择当前 Flow 内的 Node/Edge,再选择当前 Flow 外的 Node/Edge。
|
||||
4. 重新进入聚焦后,从 Flows 页切换到 Clips 或 Sources 页。
|
||||
5. 重新进入聚焦后,成功拖入或右键创建一个 Node。
|
||||
6. 分别使用 `Exit Focus`、`Show All`、下拉框 `Show All` 和画布获得焦点后的 `Esc` 退出。
|
||||
|
||||
预期:聚焦时仍保留全部节点和 Edge,只适度降低无关元素透明度;共享节点在两个 Flow 中均正常高亮;Flow 内选择保持聚焦,范围外选择、离开 Flows 资源上下文和成功创建 Node 自动退出;四种主动退出方式都恢复全部亮度,其中 `Show All` 还会框住全部元素。
|
||||
|
||||
## 7. 选择与问题定位
|
||||
|
||||
1. 从 Clip 行执行“定位引用”。
|
||||
2. 点击左侧 Flow。
|
||||
3. 执行 `Validate`,点击一个 Node 或 Edge 问题。
|
||||
4. 在聚焦某 Flow 时,定位一个不属于该 Flow 的问题。
|
||||
|
||||
预期:左侧资源、画布和右侧属性同步;Node/Edge 被框入视野;目标不属于当前 Flow 时自动切换 `Show All`,不会仅以弱化状态显示。
|
||||
|
||||
## 8. 删除与影响分析
|
||||
|
||||
### 8.1 删除 Edge
|
||||
|
||||
选择一个处于 Flow 可达路径中的 Edge,按 Delete 或点击属性区 `Disconnect Edge`。
|
||||
|
||||
预期:确认框列出受影响 Flow 和失去可达关系的 Node 数;取消不修改;确认只删除 Edge,不删除 Node 或 Flow。
|
||||
|
||||
### 8.2 删除普通 Node
|
||||
|
||||
删除一个非入口但位于 Flow 路径中的 Node。
|
||||
|
||||
预期:确认框列出受影响 Flow;确认后自动删除该 Node 的全部入边、出边和 NodeEditorData,但不删除引用的 Clip。
|
||||
|
||||
### 8.3 删除入口 Node
|
||||
|
||||
删除一个或多个 Flow 的入口 Node。
|
||||
|
||||
预期:只提供“同时删除这些 Flow”或取消;确认后 Node、关联 Edge、入口 Flow 及相应 EditorData 在同一 Undo 事务内删除。
|
||||
|
||||
### 8.4 批量删除
|
||||
|
||||
框选多个相连 Node 后按 Delete。
|
||||
|
||||
预期:只出现一次综合确认;关联 Edge 去重,不发生重复删除或异常。
|
||||
|
||||
## 9. 位置与自动布局
|
||||
|
||||
1. 拖动一个 Node,关闭并重新打开窗口。
|
||||
2. 选中两个以上 Node,点击 `Auto Layout`。
|
||||
3. 取消选择、聚焦一个 Flow,再点击 `Auto Layout`。
|
||||
4. `Show All` 后再次自动布局。
|
||||
|
||||
预期:拖动位置被保存且可 Undo;三种布局范围依次为选中集合、当前 Flow 可达节点、全部节点;布局从左到右,同层排序稳定;范围外 Node 不移动;一次布局只产生一个 Undo。
|
||||
|
||||
## 10. Undo、保存与状态恢复
|
||||
|
||||
1. 依次执行创建 Node、移动、连线、创建 Flow、改入口和删除。
|
||||
2. 连续 Undo,再连续 Redo。
|
||||
3. 点击 `Save`,关闭窗口并重新打开。
|
||||
4. 触发脚本重新编译或 Domain Reload。
|
||||
|
||||
预期:每个具名事务完整恢复;不会出现只有画布变化而数据未恢复的情况;internalId、Edge、Flow 入口和节点位置稳定;同一窗口会话内的有效 Flow 聚焦在 Undo/Redo 后重新计算,失效聚焦自动退出;窗口重开或 Domain Reload 后始终为 `Show All`,不会恢复旧聚焦。
|
||||
|
||||
## 11. 综合校验
|
||||
|
||||
点击 `Validate`,检查以下类型能够显示并定位:
|
||||
|
||||
- 缺失或重复 Node/Edge ID、无效引用、多后继、自连接、环路。
|
||||
- 终点结束覆盖与后继冲突。
|
||||
- 缺失 NodeEditorData / FlowEditorData。
|
||||
- 孤立或重复 EditorData。
|
||||
- 第一至第三阶段已有的 Clip、Flow、ImportSource 和资产所有权问题。
|
||||
|
||||
预期:轻量编辑只触发结构校验;JSON、Texture、Sprite 和 hash 只在 `Validate`、`Preview` 或 `Refresh` 时检查。
|
||||
|
||||
## 12. 回归检查
|
||||
|
||||
1. Preview / Refresh 一个 ImportSource,确认画布引用保持。
|
||||
2. 重命名 Clip,确认所有 Node.clipId 同步迁移。
|
||||
3. 重命名 Flow,确认 FlowEditorData 和默认 playable 同步迁移。
|
||||
4. 尝试删除仍被 Node 引用的 Clip。
|
||||
5. 进入第一阶段运行时样例场景,验证直接 Clip 和 Flow 播放。
|
||||
|
||||
预期:导入刷新不重建已有 Clip sub-asset;重命名引用原子迁移;被 Node 引用的 Clip 仍受删除保护;运行时行为与前三阶段一致。
|
||||
|
||||
## 13. 通过标准
|
||||
|
||||
- 无 Unity 编译错误或未处理异常。
|
||||
- 所有非法新连接和入口冲突均在写入前阻止。
|
||||
- 所有确认取消操作保持 Graph 不变。
|
||||
- Node、Edge、Flow、EditorData 的 Undo/Redo 和重载后状态一致。
|
||||
- 样例升级重复执行不增加重复 Node、Edge 或 Flow,且既有 ImportSource(包括用户添加来源)保持不变。
|
||||
- 第一至第三阶段自动测试和 PlayMode 测试无回归。
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
1. 在 Project 窗口选中 `Assets/GameContent/Huoshan/Actor/火山Graph.asset`。
|
||||
2. 点击 Inspector 中的 **Open Frame Animation Graph Editor**。
|
||||
3. 也可以从 Unity 菜单打开:**Window > Aibis Dream > Frame Animation Graph Editor**,再选择 `火山Graph`。
|
||||
3. 也可以从 Unity 菜单打开:**AIBIS > 帧动画 Graph 编辑器**,再选择 `火山Graph`。
|
||||
|
||||
编辑器左侧是 Clip / Flow 列表,中间是节点画布,右侧是当前选中内容的属性。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user