chore: 编辑器分类清理

This commit is contained in:
2026-07-23 16:25:45 +08:00
parent 28e4214413
commit 360293941a
48 changed files with 98 additions and 3226 deletions
+37
View File
@@ -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");
-149
View File
@@ -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&amp;guid=ec4ca5fe3cf523d4a88253be45a8f5c1&amp;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 -1
View File
@@ -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;
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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;
+7 -7
View File
@@ -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 环境
- Unity2022.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 与 HideTargetP1
操作:
1. 在 PlayMode Test Runner 中运行 Clear/HideTarget 相关用例。
2. 检查 SpriteRenderer 和 Image 两套目标的结果。
预期:
- ClearSprite 变为 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 与 directionP0
操作:
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 IDP0
操作:
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 合并帧共享 SpriteP0
操作:
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均显示 UnchangedSprite 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 和 FlowEditorDataGraph 只更新自身 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 帧开始播放。
- 拖动时间轴后保持 PausedPrevious/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 列表,中间是节点画布,右侧是当前选中内容的属性。