赞
踩
在上一节中,我们实现了一个基于GraphView的对话编辑器,并定义了储存对话数据的对话数据。在这一节,我们将继续完善我们的对话系统。在这一节,我们将完成:
- 对话数据文件的解析与处理
- 对话节点逻辑的实现
- 用于创建可挂载在Gameobject的Mono脚本基类
- 继承基类并创建一个简单的打字机效果对话系统
在编写脚本之前,我们先来讨论一下对话系统的状态。在一个对话系统中,我们可以将其分为三个状态,分别是对话未开始、对话中、对话结束,这是对话系统的系统状态。如图:
而在进行对话的时候,我们会有自定义语句打印效果或逻辑控制的需求,比如我们后面会实现的打字机效果。为了在对话播放时能够更加细致的进行控制,我们可以定义一个对话语句播放状态,状态包含了播放中、播放完成两个子状态。如下图:
有了上图,我们新建一个C#脚本 ,把他命名为DialogSystem.cs,打开脚本,我们首先来定义我们的状态枚举类,代码如下:
#if UNITY_EDITOR
using UnityEditor;
#endif
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
namespace DialogueSystem
{
/// <summary>
/// 对话系统抽象类
/// </summary>
public abstract class DialogueSystem : MonoBehaviour
{
/// <summary>
/// 对话系统状态
/// </summary>
public enum DialogueStates
{
/// <summary>
/// 未开始
/// </summary>
NotStart,
/// <summary>
/// 对话中
/// </summary>
Started,
/// <summary>
/// 已完成
/// </summary>
Finished
}
/// <summary>
/// 语句播放状态
/// </summary>
public enum PlayTextStates
{
/// <summary>
/// 播放中
/// </summary>
IsPlayed,
/// <summary>
/// 播放完成
/// </summary>
Finished
}
/// <summary>
/// 对话系统当前状态
/// </summary>
private DialogueStates DialogueState = DialogueStates.NotStart;
/// <summary>
/// 当前语句播放状态
/// </summary>
protected PlayTextStates PlayTextState = PlayTextStates.Finished;
}
}
定义完状态枚举类,我们接着定义几个UnityEvent,用于在状态更新时对外发出通知,可以用来进行UI的更新配置等。代码如下:
/// <summary>
/// NotStart状态回调
/// </summary>
public UnityEvent OnNotStart = new UnityEvent();
/// <summary>
/// Started状态回调
/// </summary>
public UnityEvent OnDialogueStart = new UnityEvent();
/// <summary>
/// Finished状态回调
/// </summary>
public UnityEvent OnDialogueFinish = new UnityEvent();
这样,我们就成功定义了系统的基本状态。
我们已经在编辑器里完成了对话数据的创建与编辑,接下来最重要的无非就是怎么去用这些对话数据了。我们的对话数据都是一个个的Scriptableobject对象,所以我们可以直接在脚本中创建一个DialogTree类型的字段,直接引用我们项目中保存的DialogTree对象即可。代码如下:
/// <summary>
/// 对话数据
/// </summary>
public DialogTree DialogTree;
在Unity中创建一个空的Gameobject,将DialogSystem脚本挂上去,将一个DialogTree文件挂载上去,这样就完成了对DialogTree对象的引用了。有了DialogTree对象,我们可以在脚本中读取里面的节点信息了,打开DialogSystem脚本,添加代码如下:
/// <summary>
/// TreeData是否读取结束
/// </summary>
private bool IsLoadDialogTreeDataEnd = false;
/// <summary>
/// 当前对话节点
/// </summary>
private DialogNodeDataBase CurrentDialogNode;
/// <summary>
/// 初始化对话树数据
/// </summary>
private void InitDialogTreeData()
{
if (DialogTree.StartNodeData == null)
{
Debug.LogError("The Start node does not exist in the DialogTree file");
return;
}
//StartNode只有一个接口
CurrentDialogNode = DialogTree.StartNodeData.ChildNode[0];
IsLoadDialogTreeDataEnd = false;
}
上面的代码创建了一个currentDialogNode变量,用来储存我们当前正要读取的节点。并且我们定义了一个用于初始化读取的方法InitDialogTreeData(),该方法读取对话树文件中的StartNode对象,并将其设置给currentDialogNode,即当前待读取的节点。到此,对话系统对数据的初始化完成。
对话系统的核心,其实就是是维护一个字符串队列。而输出对话内容,其实就是使字符串数据从这个队列里出列。我们打开DialogSystem脚本,添加以下代码:
/// <summary>
/// 对话数据队列
/// </summary>
public Queue<string> SentenceQueue;
/// <summary>
/// 开启对话系统方法
/// </summary>
public void StartDialogue()
{
if (DialogueState == DialogueStates.NotStart)
{
if (DialogTree == null)
{
Debug.LogError("The DialogTree file is missing");
return;
}
DialogueState = DialogueStates.Started;
OnDialogueStart?.Invoke();
//先播放第一句
Next();
}
}
/// <summary>
/// 继续对话方法
/// </summary>
public void Next()
{
if (DialogueState != DialogueStates.Started)
{
return;
}
if (IsSelecting)
{
return;
}
if (SentenceQueue.Count > 0)
{
switch (PlayTextState)
{
case PlayTextStates.Finished:
{
// 用来输出文本的抽象方法,后面会讲
PlayText(SentenceQueue.Dequeue());
break;
}
case PlayTextStates.IsPlayed:
{
// 在对话语句播放期间调用Next方法时调用,该方法为抽象方法,后面会讲
NextOnTextIsPlayed();
break;
}
}
}
else
{
//加载当前节点
LoadCurrentDialogNode();
if (IsLoadDialogTreeDataEnd)
{
DialogueState = DialogueStates.Finished;
OnDialogueFinish?.Invoke();
}
else
{
//递归
Next();
}
}
}
在上面的代码中,我们对外提供了几个方法,用于操作对话系统。其中在Next方法中,对话数据的读取是一个递归的过程。当对话队列里对象为空,且当前对话节点未进行至EndNode时,程序会读取当前待读取的节点。读取节点用到了一个LoadCurrentDialogNode方法,我们就来实现他。代码如下:
/// <summary>
/// 加载当前对话节点
/// </summary>
private void LoadCurrentDialogNode()
{
if (IsLoadDialogTreeDataEnd)
{
return;
}
if (CurrentDialogNode == null)
{
Debug.LogError("The branch has ended but the EndNode is not connected");
//保护措施
IsLoadDialogTreeDataEnd = true;
return;
}
// 检测节点类型,并进行对应处理
switch (CurrentDialogNode.NodeType)
{
case NodeType.SequentialDialogNode:
{
// todo
break;
}
case NodeType.RandomDialogNode:
{
// todo
break;
}
case NodeType.End:
{
// todo
break;
}
}
}
在LoadCurrentDialogNode方法中,对于任意的节点,我们首先检测节点的类型,得到节点类型后,我们就可以根据不同的类型,对节点进行不同的处理。
这样,我们任何一个节点,都由这三部分构成,储存数据的Data,负责UI的View,还有负责各节点逻辑的LoadCurrentDialogNode方法。这种架构有点类似MVC架构。在架构的帮助下,我们不仅可以快速拓展新的节点,而且对象间的耦合度低。我们来完成节点的具体逻辑,完善LoadCurrentDialogNode方法,代码如下:
/// <summary>
/// 加载当前对话节点
/// </summary>
private void LoadCurrentDialogNode()
{
if (IsLoadDialogTreeDataEnd)
{
return;
}
if (CurrentDialogNode == null)
{
Debug.LogError("The branch has ended but the EndNode is not connected");
//保护措施
IsLoadDialogTreeDataEnd = true;
return;
}
switch (CurrentDialogNode.NodeType)
{
case NodeType.SequentialDialogNode:
{
foreach (var output in CurrentDialogNode.OutputItems)
{
// 按顺序全部入列
SentenceQueue.Enqueue(output);
}
// 设置下一个待读取节点
CurrentDialogNode = CurrentDialogNode.ChildNode[0];
break;
}
case NodeType.RandomDialogNode:
{
if (CurrentDialogNode.OutputItems.Count > 0)
{
// 选择一个随机语句入列
SentenceQueue.Enqueue(
CurrentDialogNode.OutputItems[Random.Range(0, CurrentDialogNode.OutputItems.Count)]);
}
CurrentDialogNode = CurrentDialogNode.ChildNode[0];
break;
}
case NodeType.End:
{
// 结束
IsLoadDialogTreeDataEnd = true;
break;
}
}
}
完成了节点的逻辑部分,我们也差不多该收尾了,继续编辑DialogueSystem脚本,对外提供一个重置对话的方法,并在脚本Awake的时候调用一下。代码如下:
/// <summary>
/// 重置对话方法
/// </summary>
public void Reset()
{
SentenceQueue = new Queue<string>();
DialogueState = DialogueStates.NotStart;
IsLoadDialogTreeDataEnd = false;
OnNotStart?.Invoke();
InitDialogTreeData();
}
void Awake()
{
//初始化
Reset();
}
到这,对话系统的处理就完成了。接下来,是对话系统的对话输出部分。
做完了对话数据的处理,我们是时候让对话系统跑起来了。作为一个对话系统,我们肯定要能够输出对话,那么,如何输出对话就是我们接下来要解决的问题。
在输出对话的时候,我们可能会有不同的输出需求,如逐字输出啊、实时对对话进行一些处理等。而且,我们的对话文本也并非只能以固定的方式进行输出。为了实现这一点,我们将字符串输出做成一个事件,代码如下:
/// <summary>
/// 用于设置文本输出目标
/// </summary>
public UnityEvent<string> OnPlayText = new UnityEvent<string>();
/// <summary>
/// 播放对话语句方法,该方法在子类中实现,可自定义语句打印效果
/// 子类必须在打印语句时将PlayTextState设置为IsPlayed状态,打印完成时必须将其设置为Finished状态
/// </summary>
/// <param name="sentence">当前对话语句</param>
protected abstract void PlayText(string sentence);
/// <summary>
/// 状态监听方法,该方法在子类中实现,该方法在PlayTextState为IsPlayed时尝试继续对话时被调用,可在子类中监听并进行处理
/// </summary>
protected abstract void NextOnTextIsPlayed();
/// <summary>
/// 对外输出语句方法
/// </summary>
/// <param name="text">文本</param>
protected void OutputText(string text)
{
if (text == null)
{
return;
}
OnPlayText?.Invoke(text);
}
创建事件的同时,我们也定义了两个抽象方法,PlayText跟NextOnTextIsPlayed。这两个方法要求在子类中实现,其中PlayText方法会在打印对话语句时调用,NextOnTextIsPlayed则是在对话播放还尚未结束的时候调用Next方法时会被调用。
利用这两个抽象方法,我们能对对话播放进行细致的自定义操作。另外我们也封装了一个用于对外发送输出文本事件的函数,该函数主要还是用于在对话系统类中使用。
到此,我们的对话系统基类的雏形就完成了,以后只要继承该类,就能快速根据自己需求拓展出不同的对话组件。
要创建一个自定义的对话系统组件,只要继承对话系统抽象类,并实现抽象方法即可。下面,我们以实现一个打字机效果的对话系统为例,演示一下如何自定义我们自己的对话系统类。
新建一个TypingEffectsDialogue.cs脚本,添加代码如下:
using System;
using System.Collections;
using LFramework.Kit.DialogueSystem;
using UnityEngine;
public class TypingEffectsDialogue : DialogueSystem
{
/// <summary>
/// 文本打印速度
/// </summary>
public float TypingSpeed;
/// <summary>
/// 缓存当前对话文本
/// </summary>
private string CurrentSentence = String.Empty;
/// <summary>
/// 缓存对话打印协程
/// </summary>
private Coroutine TextEffectCoroutine;
protected override void PlayText(string sentence)
{
//打印前将对话状态设定为IsPlayed
PlayTextState = PlayTextStates.IsPlayed;
CurrentSentence = sentence;
TextEffectCoroutine = StartCoroutine(StartPlayText());
}
/// <summary>
/// 打字机效果
/// </summary>
/// <returns></returns>
private IEnumerator StartPlayText()
{
string sentenceToPlay = string.Empty;
var length = CurrentSentence.Length;
for (var i = 0; i <= length; i++)
{
yield return new WaitForSeconds(TypingSpeed);
sentenceToPlay = CurrentSentence.Substring(0, i);
OutputText(sentenceToPlay);
//OnPlayText?.Invoke(sentenceToPlay);
}
//打印完成后将对话状态设定为Finished
PlayTextState = PlayTextStates.Finished;
}
}
要实现打字机效果,我们可以使用一个协程来完成。在协程中不断循环递增分割目标字符串即可。值得注意点是,在我们执行耗时效果之前,我们应该将语句播放状态设置为播放中(IsPlayed),并在语句播放完成时将状态设置成播放完成(Finished)。这样当语句播放未完成时调用Next方法,系统会自动调用NextOnTextIsPlayed方法。在这里,我们能对玩家的输入行为进行响应,做出相应处理。
比如在我们的打字机效果未完成时调用Next方法,我们可以直接跳过效果,输出完整的对话语句。实现NextOnTextIsPlayed抽象方法,代码如下:
protected override void NextOnTextIsPlayed()
{
StopCoroutine(TextEffectCoroutine);
OnPlayText?.Invoke(CurrentSentence);
//打印完成后将对话状态设定为Finished
PlayTextState = PlayTextStates.Finished;
}
就像上面的例子,简单的实现对话系统基类提供的抽象方法,我们就能自定义出不同需求的对话组件。下面,我们来演示一下如何利用该组件,快速创建一个运行在游戏中的对话系统。
首先新建一个空物体,命名为DialogueManager,将我们的TypingEffectsDialogue.cs脚本拖拽到物体上面,可以看到Inspector选项卡里出现的参数,如下图:
首先要配置的是我们的DialogTree对象,选择一个创建编辑好的DialogTree文件,将其拖拽到对话系统图开放的参数中。
对话文件进行了简单的设置,大家可以看看内容:
接下来我们来创建的一个简单的对话UI,首先在Canvas里创建一个文本(),再创建3个按钮(Button),分别命名为Start、Next和Reset。UI参考如下:
UI层级:
创建完UI后,我们来设置各种参数,首先是三个按钮,分别给他们增加一个新的点击事件,分别调用DialogueManager中的StartDialog()、Next()、Reset()方法。
接着点击DialogueManager,可以在面板上看到三个状态事件,我们来配置他们以对UI进行控制。
按照上述逻辑我们设置如下:
配置完了按钮UI逻辑,我们来配置文本输出的UI,查看面板的OnPlayText事件,将我们的Text组件设置进去,目标就是我们Text组件的text参数。如下:
最后,在OnStarted和OnFinish增加新的事件,在对话开始时激活Text对象,而在对话结束和对话未开始时将其设置为未激活。如下:
现在,我们运行项目,测试一下对话系统。
可以看到,对话系统正常运行。
现在,我们已经完成了一个可用的简易对话系统,他通过从外部引用DialogTree对象来加载对话数据。但这种方式并不是很灵活,我们可以对外提供一些更加灵活点加载方法,开发更加方便。
我们可以提供一个加载方法,利用所传入的相对路径加载DialogTree对象。代码如下:
/// <summary>
/// 通过路径设置
/// </summary>
/// <param name="path">DialogTree对象相对路径</param>
public void SetDialogTree(string path)
{
var dialogTree = AssetDatabase.LoadAssetAtPath<DialogTree>(path);
//var dialogTree = Resources.Load(path);
if (dialogTree == null)
{
Debug.LogError("Load DialogTree in path:" + path + " failed!");
return;
}
if (typeof(DialogTree) == DialogTree.GetType())
{
DialogTree = dialogTree;
}
if (DialogueState != DialogueStates.Started)
{
InitDialogTreeData();
}
}
提供一个加载方法,直接将所传入的DialogTree对象加载到对话系统中。代码如下:
/// <summary>
/// 通过对象设置
/// </summary>
/// <param name="dialogTree">DialogTree对象</param>
public void SetDialogTree(DialogTree dialogTree)
{
if (dialogTree == null)
{
Debug.LogError($"The DialogTree: {dialogTree} object is Null");
return;
}
DialogTree = dialogTree;
if (DialogueState != DialogueStates.Started)
{
InitDialogTreeData();
}
}
通过以上的方式,我们能更加灵活的在开发中使用最符合项目实际情况的加载方式,当然我们也能继续增加新的加载方式,大家可以根据需求继续拓展。比如后续我们打算引入的仓库模式用来管理对话数据,这部分就留着以后的章节有机会再讨论了。
到此,本章节也进入了尾声。在本章节中,我们实现了对话数据的解析和对话节点的逻辑处理。到这时,我们总算是形成了节点对话系统的完整架构,即Data、View与Controller层。Data层负责节点数据的存储,View层负责节点在编辑器中的表现,而Controller层则是对节点在运行期间的逻辑进行控制。该架构其实很类似MVC架构,不过在此之上我们还有对编辑器与非编辑器部分进行了分层,这部分可以查看一下上一节文章。
总之,我们得到了一个非常易于拓展的对话系统,对于任意一个新的节点,我们只要分别考虑并实现他们的三个层,就能拓展出一个新的节点,后面我们会拓展更多的节点,例如分支选择对话节点,角色切换节点,事件节点,同时对话节点等等。但在此之前,现阶段我们的对话节点在资源管理方面还是存在很多的问题,比如节点数据不会在对话树删除后自动处理等,我们将在下一章来完善这些功能。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。