问题背景 我正在制作一个npc对话系统,台词作为节点,选项产生分支,在逻辑上形成一个有像无环图结构。台词和选项的持久化保存方式,我使用了Unity自带的ScriptableObject(以下简称SO),我希望通过他们的互相引用来实现这个图结构,只要对任何一个台词SO作为对话系统的初始节点,就可以以此为起点来对整个对话图进行一次深度优先搜索,同时,也支持在任何对话/选项结束后跳转到任一台词功能。
用excel来作为配置表编辑工具,用EPPlus来作为读表工具,生成对应的SO。
台词 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 using UnityEngine;[CreateAssetMenu(fileName = "Dialogue" , menuName = "Data/Dialogue" ) ] [System.Serializable ] public class Dialogue : ScriptableObject { public string dialogueText; public string characterName; public SpritePosition spritePosition; public Sprite characterSprite; public Dialogue next; public DialogueOption[] options; } public enum SpritePosition { Left,Right }
选项 1 2 3 4 5 6 7 using UnityEngine;[CreateAssetMenu(fileName = "DialogueOption" , menuName = "Data/Dialogue Option" ) ] public class DialogueOption : ScriptableObject { public string optionText; public Dialogue nextDialogue; }
配置表结构 sheet1:
ID(从1开始顺序增加不可变动)
人物
内容
图片
位置
下个台词的ID(-1为结束)
选项1ID(可空)
选项2ID(可空)
选项3ID(可空)
1
诺斯替
这里是……?
Assets/Graphics/Portrait/Gnostic_nobg.PNG
左
2
2
骷髅
ZZZZZZ……
Assets/Graphics/Portrait/Target_nobg.PNG
右
3
1
2
3
3
诺斯替
你好?醒醒
Assets/Graphics/Portrait/Gnostic_nobg.PNG
左
4
4
骷髅
哦…哦!你好啊,你终于来了,我的勇者。
Assets/Graphics/Portrait/Target_nobg.PNG
右
5
5
诺斯替
我是勇者?现在是什么情况。。。
Assets/Graphics/Portrait/Gnostic_nobg.PNG
左
6
6
骷髅
别问这么多,你先按下J键或者手柄的A键,对我开炮就完事了,我血条很厚,你可以拿我当靶子
Assets/Graphics/Portrait/Target_nobg.PNG
右
7
7
诺斯替
哦哦好的
Assets/Graphics/Portrait/Gnostic_nobg.PNG
左
8
8
骷髅
行了没别的事别来烦我。
Assets/Graphics/Portrait/Target_nobg.PNG
右
-1
4
sheet2:
ID(从1开始顺序增加不可变动)
选项内容
对应的台词ID(-1为结束对话)
1
叫醒他
3
2
还是叫醒他吧
3
3
必须叫醒他
3
4
收到
-1
Editor脚本(原) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEditor;using OfficeOpenXml;using System;using System.IO;# if UNITY_EDITOR [CustomEditor(typeof(DialogueManager)) ] public class DialogueManagerEditor : Editor { private DialogueManager _target; public override void OnInspectorGUI () { base .OnInspectorGUI(); if (GUILayout.Button("点我来应用配置表" )) { GenerateDialoguesFromExcel(); } } private void OnEnable () { _target = (DialogueManager)target; } public static void GenerateDialoguesFromExcel () { string folderPath = "Assets/Data/Dialogue/" ; string dialogueExcelPath = Path.Combine(folderPath, "DialogueSheet.xlsx" ); string dialogueOptionExcelPath = Path.Combine(folderPath, "DialogueOptionSheet.xlsx" ); ClearDialoguesInFolder(folderPath); try { using (var package = new ExcelPackage(new FileInfo(dialogueExcelPath))) { ExcelWorksheet dialogueSheet = package.Workbook.Worksheets[1 ]; Dialogue[] dialogues = new Dialogue[dialogueSheet.Dimension.End.Row]; for (int i = 1 ; i < dialogues.Length; i++) { dialogues[i] = ScriptableObject.CreateInstance<Dialogue>(); } ExcelWorksheet dialogueOptionSheet = package.Workbook.Worksheets[2 ]; DialogueOption[] dialogueOptions = new DialogueOption[dialogueOptionSheet.Dimension.End.Row]; for (int i = 1 ; i < dialogueOptions.Length; i++) { dialogueOptions[i] = ScriptableObject.CreateInstance<DialogueOption>(); } for (int row = dialogueSheet.Dimension.Start.Row + 1 ; row <= dialogueSheet.Dimension.End.Row; row++) { int index = int .Parse(dialogueSheet.Cells[row, 1 ].Text); string characterName = dialogueSheet.Cells[row, 2 ].Text; string dialogueText = dialogueSheet.Cells[row, 3 ].Text; string imagePath = dialogueSheet.Cells[row, 4 ].Text; SpritePosition spritePos = dialogueSheet.Cells[row, 5 ].Text == "左" ? SpritePosition.Left : SpritePosition.Right; int nextIndex = int .Parse(dialogueSheet.Cells[row, 6 ].Text); List<int > optionIds = new List<int >(); for (int col = 7 ; col <= dialogueSheet.Dimension.End.Column; col++) { string optionIdText = dialogueSheet.Cells[row, col].Text; if (!string .IsNullOrEmpty(optionIdText)) { if (int .TryParse(optionIdText, out int optionId)) { optionIds.Add(optionId); } } } int [] optionIdArray = optionIds.ToArray(); Dialogue dialogue = dialogues[index]; dialogue.dialogueText = dialogueText; dialogue.characterName = characterName; dialogue.spritePosition = spritePos; dialogue.characterSprite = AssetDatabase.LoadAssetAtPath<Sprite>(imagePath); dialogue.next = nextIndex >= 1 ? dialogues[nextIndex] : null ; dialogue.options = new DialogueOption[optionIdArray.Length]; for (int i = 0 ; i < optionIdArray.Length; i++) { dialogue.options[i] = dialogueOptions[optionIdArray[i]]; } string assetPath = $"{folderPath} Dialogue_{index} .asset" ; AssetDatabase.CreateAsset(dialogue, assetPath); } for (int row = dialogueOptionSheet.Dimension.Start.Row + 1 ; row <= dialogueOptionSheet.Dimension.End.Row; row++) { int index = int .Parse(dialogueSheet.Cells[row, 1 ].Text); string optionText = dialogueOptionSheet.Cells[row, 2 ].Text; int nextDialogueIndex = int .Parse(dialogueOptionSheet.Cells[row, 3 ].Text); DialogueOption dialogueOption = dialogueOptions[index]; dialogueOption.optionText = optionText; dialogueOption.nextDialogue = nextDialogueIndex >= 1 ? dialogues[nextDialogueIndex] : null ; string assetPath = $"{folderPath} DialogueOption_{index} .asset" ; AssetDatabase.CreateAsset(dialogueOption, assetPath); } } AssetDatabase.SaveAssets(); Debug.Log("对话资源已成功生成" ); } catch (Exception e) { Debug.LogError($"处理失败: {e.Message} " ); } } private static void ClearDialoguesInFolder (string path ) { foreach (string guid in AssetDatabase.FindAssets("t:Dialogue" , new [] { path })) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); AssetDatabase.DeleteAsset(assetPath); } } } #endif
问题描述 第二天打开Unity的时候,发现生成所有的SO,其中对于其他SO的引用全部丢失。在重新生成资源并重启Unity后,发现依然丢失索引。
解决方案 查阅资料后,发现是因为Dialogue引用的是未被序列化的资产,因此无法持久化保存。CreateInstance 只是在内存中创建数据,没有保存到磁盘。
一个ScriptableObject应该作为一个独立的资源文件,或者作为另一个资源的子资源(sub-asset)。但不能同时作为独立资源和另一个资源的子资源。
也就是说,如果两个SO都已经执行过AssetDatabase.CreateAsset
了,那么二者是不能进行相互引用的。
因此,正确的流程如下:
实例化所有SO对象,但是跳过引用部分。
设置所有SO对象的引用
对所有父资源执行AssetDatabase.CreateAsset
对于每一个子资源,遍历所有父资源,该子资源对应的父亲,执行AssetDatabase.AddObjectToAsset
,否则执行AssetDatabase.CreateAsset
作为独立资源。
所有资源写入磁盘:AssetDatabase.SaveAssets()
然而如果按照这样的流程的话,虽然可以对DialogueOption的数据进行持久化保存,但是对于Dialogue类型的next字段,依然无法进行持久化保存,因为两个Dialogue都是父资源,无法用”AssetDatabase.AddObjectToAsset”来获取引用。
目前的方法就是在最后对所有SO执行EditorUtility.SetDirty
,强行执行持久化保存。
根据如上流程,对生成资源的代码进行修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEditor;using OfficeOpenXml;using System;using System.IO;#if UNITY_EDITOR [CustomEditor(typeof(DialogueManager)) ] public class DialogueManagerEditor : Editor { private DialogueManager _target; public override void OnInspectorGUI () { base .OnInspectorGUI(); if (GUILayout.Button("点我来应用配置表" )) { GenerateDialoguesFromExcel(); } } private void OnEnable () { _target = (DialogueManager)target; } public static void GenerateDialoguesFromExcel () { string folderPath = "Assets/Data/Dialogue/" ; string dialogueExcelPath = Path.Combine(folderPath, "DialogueSheet.xlsx" ); string dialogueOptionExcelPath = Path.Combine(folderPath, "DialogueOptionSheet.xlsx" ); ClearDialoguesInFolder(folderPath); try { if (!Directory.Exists(folderPath)) { Directory.CreateDirectory(folderPath); } using (var package = new ExcelPackage(new FileInfo(dialogueExcelPath))) { ExcelWorksheet dialogueSheet = package.Workbook.Worksheets[1 ]; ExcelWorksheet dialogueOptionSheet = package.Workbook.Worksheets[2 ]; Dictionary<int , Dialogue> dialogues = new Dictionary<int , Dialogue>(); Dictionary<int , DialogueOption> dialogueOptions = new Dictionary<int , DialogueOption>(); for (int row = 2 ; row <= dialogueSheet.Dimension.End.Row; row++) { if (dialogueSheet.Cells[row, 1 ].Text == "" ) continue ; int index = int .Parse(dialogueSheet.Cells[row, 1 ].Text); Dialogue dialogue = ScriptableObject.CreateInstance<Dialogue>(); dialogue.characterName = dialogueSheet.Cells[row, 2 ].Text; dialogue.dialogueText = dialogueSheet.Cells[row, 3 ].Text; string imagePath = dialogueSheet.Cells[row, 4 ].Text; dialogue.characterSprite = AssetDatabase.LoadAssetAtPath<Sprite>(imagePath); dialogue.spritePosition = dialogueSheet.Cells[row, 5 ].Text == "左" ? SpritePosition.Left : SpritePosition.Right; dialogues.Add(index, dialogue); } for (int row = 2 ; row <= dialogueOptionSheet.Dimension.End.Row; row++) { if (dialogueOptionSheet.Cells[row, 1 ].Text == "" ) continue ; int index = int .Parse(dialogueOptionSheet.Cells[row, 1 ].Text); DialogueOption option = ScriptableObject.CreateInstance<DialogueOption>(); option.optionText = dialogueOptionSheet.Cells[row, 2 ].Text; dialogueOptions.Add(index, option); } for (int row = 2 ; row <= dialogueSheet.Dimension.End.Row; row++) { if (dialogueSheet.Cells[row, 1 ].Text == "" ) continue ; int index = int .Parse(dialogueSheet.Cells[row, 1 ].Text); Dialogue dialogue = dialogues[index]; int nextIndex = int .Parse(dialogueSheet.Cells[row, 6 ].Text); if (nextIndex > 0 && dialogues.ContainsKey(nextIndex)) { dialogue.next = dialogues[nextIndex]; } List<DialogueOption> options = new List<DialogueOption>(); for (int col = 7 ; col <= dialogueSheet.Dimension.End.Column; col++) { string optionIdText = dialogueSheet.Cells[row, col].Text; if (!string .IsNullOrEmpty(optionIdText) && int .TryParse(optionIdText, out int optionId)) { if (dialogueOptions.ContainsKey(optionId)) { options.Add(dialogueOptions[optionId]); } } } dialogue.options = options.ToArray(); } for (int row = 2 ; row <= dialogueOptionSheet.Dimension.End.Row; row++) { if (dialogueOptionSheet.Cells[row, 1 ].Text == "" ) continue ; int index = int .Parse(dialogueOptionSheet.Cells[row, 1 ].Text); int nextDialogueIndex = int .Parse(dialogueOptionSheet.Cells[row, 3 ].Text); if (nextDialogueIndex > 0 && dialogues.ContainsKey(nextDialogueIndex)) { dialogueOptions[index].nextDialogue = dialogues[nextDialogueIndex]; } } foreach (var kvp in dialogues) { string assetPath = $"{folderPath} Dialogue_{kvp.Key} .asset" ; AssetDatabase.CreateAsset(kvp.Value, assetPath); } foreach (var kvp in dialogueOptions) { Dialogue parentDialogue = null ; foreach (var dialogue in dialogues.Values) { if (dialogue.options != null && Array.Exists(dialogue.options, o => o == kvp.Value)) { parentDialogue = dialogue; break ; } } if (parentDialogue != null ) { kvp.Value.name = $"Option_{kvp.Key} " ; AssetDatabase.AddObjectToAsset(kvp.Value, parentDialogue); } else { string assetPath = $"{folderPath} DialogueOption_{kvp.Key} .asset" ; AssetDatabase.CreateAsset(kvp.Value, assetPath); } } foreach (var dialogue in dialogues.Values) { EditorUtility.SetDirty(dialogue); } foreach (var option in dialogueOptions.Values) { EditorUtility.SetDirty(option); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log("对话资源已成功生成" ); } } catch (Exception e) { Debug.LogError($"处理失败: {e.Message} \n{e.StackTrace} " ); } } private static void ClearDialoguesInFolder (string path ) { foreach (string guid in AssetDatabase.FindAssets("t:ScriptableObject" , new [] { path })) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); if (AssetDatabase.LoadMainAssetAtPath(assetPath) != null ) { AssetDatabase.DeleteAsset(assetPath); } } } } #endif
用文本编辑器打开Dialogue_1.asset
和Dialogue_2.asset
后,观察可以得出二者的不同。
Dialogue_1.asset:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 %YAML 1.1 %TAG !u ! tag:unity3d.com,2011: --- !u!114 &11400000 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0 } m_PrefabInstance: {fileID: 0 } m_PrefabAsset: {fileID: 0 } m_GameObject: {fileID: 0 } m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000 , guid: 36fd073155687fb41a04bd05aa153e67 , type: 3 } m_Name: Dialogue_1 m_EditorClassIdentifier: dialogueText: "\u8FD9\u91CC\u662F......?" characterName: "\u8BFA\u65AF\u66FF" spritePosition: 0 characterSprite: {fileID: 21300000 , guid: ce7f0f330c16de84dafbbee698018256 , type: 3 } next: {fileID: 11400000 , guid: 67a9a5d0ab5932844ac8de0737850288 , type: 2 } options: []
Dialogue_2.asset:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 %YAML 1.1 %TAG !u ! tag:unity3d.com,2011: --- !u!114 &-3423777264897482373 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0 } m_PrefabInstance: {fileID: 0 } m_PrefabAsset: {fileID: 0 } m_GameObject: {fileID: 0 } m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000 , guid: 468dffe29d36aa44db6a548c56a8243e , type: 3 } m_Name: Option_1 m_EditorClassIdentifier: optionText: "\u53EB\u9192\u4ED6" nextDialogue: {fileID: 11400000 , guid: f7f1ac9cbc3106b4b801c783340039ec , type: 2 } --- !u!114 &11400000 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0 } m_PrefabInstance: {fileID: 0 } m_PrefabAsset: {fileID: 0 } m_GameObject: {fileID: 0 } m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000 , guid: 36fd073155687fb41a04bd05aa153e67 , type: 3 } m_Name: Dialogue_2 m_EditorClassIdentifier: dialogueText: ZZZZZZ...... characterName: "\u9AB7\u9AC5" spritePosition: 1 characterSprite: {fileID: 21300000 , guid: c05020f379adbe045b6691ce3eb158a2 , type: 3 } next: {fileID: 11400000 , guid: f7f1ac9cbc3106b4b801c783340039ec , type: 2 } options: - {fileID: -3423777264897482373 } - {fileID: 6968124649925995447 } - {fileID: 7906954922346332785 } --- !u!114 &6968124649925995447 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0 } m_PrefabInstance: {fileID: 0 } m_PrefabAsset: {fileID: 0 } m_GameObject: {fileID: 0 } m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000 , guid: 468dffe29d36aa44db6a548c56a8243e , type: 3 } m_Name: Option_2 m_EditorClassIdentifier: optionText: "\u8FD8\u662F\u53EB\u9192\u4ED6\u5427" nextDialogue: {fileID: 11400000 , guid: f7f1ac9cbc3106b4b801c783340039ec , type: 2 } --- !u!114 &7906954922346332785 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0 } m_PrefabInstance: {fileID: 0 } m_PrefabAsset: {fileID: 0 } m_GameObject: {fileID: 0 } m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000 , guid: 468dffe29d36aa44db6a548c56a8243e , type: 3 } m_Name: Option_3 m_EditorClassIdentifier: optionText: "\u5FC5\u987B\u53EB\u9192\u4ED6" nextDialogue: {fileID: 11400000 , guid: f7f1ac9cbc3106b4b801c783340039ec , type: 2 }
可以看出来,引用是引用,子资源是子资源。引用只是在资源中设置了一个指向其他资源的指针,而子资源的所有内容都和父资源在同一个资源文件中。