Unity 基于GraphView的对话系统设计(二)节点逻辑处理与对话组件

在上一节中,我们实现了一个基于GraphView的对话编辑器,并定义了储存对话数据的对话数据。在这一节,我们将继续完善我们的对话系统。在这一节,我们将完成:
  • 对话数据文件的解析与处理
  • 对话节点逻辑的实现
  • 用于创建可挂载在Gameobject的Mono脚本基类
  • 继承基类并创建一个简单的打字机效果对话系统

创建对话系统基类

定义系统状态

在编写脚本之前,我们先来讨论一下对话系统的状态。在一个对话系统中,我们可以将其分为三个状态,分别是对话未开始、对话中、对话结束,这是对话系统的系统状态。如图:

DialogueStates
对话系统状态
NotStart
未开始
Started
对话中
Finished
已完成

而在进行对话的时候,我们会有自定义语句打印效果或逻辑控制的需求,比如我们后面会实现的打字机效果。为了在对话播放时能够更加细致的进行控制,我们可以定义一个对话语句播放状态,状态包含了播放中、播放完成两个子状态。如下图:

PlayTextStates
语句播放状态
IsPlayed
播放中
Finished
播放完成

有了上图,我们新建一个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选项卡里出现的参数,如下图:

对话组件Inspector
首先要配置的是我们的DialogTree对象,选择一个创建编辑好的DialogTree文件,将其拖拽到对话系统图开放的参数中。

设置对话树文件
对话文件进行了简单的设置,大家可以看看内容:

简单的例子
接下来我们来创建的一个简单的对话UI,首先在Canvas里创建一个文本(),再创建3个按钮(Button),分别命名为Start、Next和Reset。UI参考如下:

UI参考
UI层级:

层级

创建完UI后,我们来设置各种参数,首先是三个按钮,分别给他们增加一个新的点击事件,分别调用DialogueManager中的StartDialog()、Next()、Reset()方法。

Button事件

接着点击DialogueManager,可以在面板上看到三个状态事件,我们来配置他们以对UI进行控制。

  • 在对话未开始(OnNotStart)时,Next、Reset按钮应该处于设置为未激活状态,而Start按钮则应该激活。
  • 在对话开始(OnStarted)时,Start、Reset按钮应该处于设置为未激活状态,而Next按钮则应该激活。
  • 在对话结束(OnFinish)时,Start、Next按钮应该处于设置为未激活状态,而Reset按钮则应该激活。

按照上述逻辑我们设置如下:

对话系统组件

配置完了按钮UI逻辑,我们来配置文本输出的UI,查看面板的OnPlayText事件,将我们的Text组件设置进去,目标就是我们Text组件的text参数。如下:
设置Text

最后,在OnStarted和OnFinish增加新的事件,在对话开始时激活Text对象,而在对话结束和对话未开始时将其设置为未激活。如下:

在这里插入图片描述

现在,我们运行项目,测试一下对话系统。

测试

可以看到,对话系统正常运行。

拓展对话系统加载DialogTree的方式

现在,我们已经完成了一个可用的简易对话系统,他通过从外部引用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对象进行加载

提供一个加载方法,直接将所传入的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架构,不过在此之上我们还有对编辑器与非编辑器部分进行了分层,这部分可以查看一下上一节文章。

总之,我们得到了一个非常易于拓展的对话系统,对于任意一个新的节点,我们只要分别考虑并实现他们的三个层,就能拓展出一个新的节点,后面我们会拓展更多的节点,例如分支选择对话节点,角色切换节点,事件节点,同时对话节点等等。但在此之前,现阶段我们的对话节点在资源管理方面还是存在很多的问题,比如节点数据不会在对话树删除后自动处理等,我们将在下一章来完善这些功能。

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

◎Bigotry◎

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值