问题背景

我正在制作一个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>();
}


////////////////////////////

///// 台词SO生成
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);
//选项id提取
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);
}

///// 选项SO生成
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了,那么二者是不能进行相互引用的。

因此,正确的流程如下:

  1. 实例化所有SO对象,但是跳过引用部分。
  2. 设置所有SO对象的引用
  3. 对所有父资源执行AssetDatabase.CreateAsset
  4. 对于每一个子资源,遍历所有父资源,该子资源对应的父亲,执行AssetDatabase.AddObjectToAsset,否则执行AssetDatabase.CreateAsset作为独立资源。
  5. 所有资源写入磁盘: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>();

// 第一遍:创建所有Dialogue对象(不设置引用)
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);
}

// 第一遍:创建所有DialogueOption对象(不设置引用)
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];

// 设置next引用
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();
}

// 第二遍:设置选项的next引用
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];
}
}

// 保存所有Dialogue
foreach (var kvp in dialogues)
{
string assetPath = $"{folderPath}Dialogue_{kvp.Key}.asset";
AssetDatabase.CreateAsset(kvp.Value, assetPath);
}

// 保存所有DialogueOption(作为主资源的子对象)
foreach (var kvp in dialogueOptions)
{
// 查找所属的主Dialogue
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.assetDialogue_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}

可以看出来,引用是引用,子资源是子资源。引用只是在资源中设置了一个指向其他资源的指针,而子资源的所有内容都和父资源在同一个资源文件中。