Unity对话系统设计与实现(一):模块化、状态机与对象池优化

Source

在游戏开发中,对话系统是实现游戏叙事和角色互动的重要模块。本文将分享一个完整的Unity对话系统实现方案,该系统采用模块化设计,结合状态机管理对话流程,使用对象池优化UI性能,并支持条件分支和事件回调等高级功能。

1.系统架构

整个对话系统分为四大核心模块:

  1. 数据配置层:使用ScriptableObject管理对话数据

  2. 控制层:对话流程状态管理

  3. 逻辑层:对话条件检测与事件处理

  4. 表现层:UI显示与用户交互

2.核心模块详解

2.1 数据配置模块

使用ScriptableObject实现可视化配置:

using System;
using DialogueSystem;
using UnityEngine;

[CreateAssetMenu(fileName = "DialogueDataConfig", menuName = "AssetConfig(临时配置)/DialogueSystem/DataConfig")]
public class DialogueDataConfig : ScriptableObject
{
    /// <summary>
    /// 所有的对话段落
    /// </summary>
    public DialogueGroupData[] DialogueGroups;
}

[Serializable]
public class DialogueGroupData
{
    /// <summary>
    /// 每个对话段落的id
    /// </summary>
    [Header("每个对话段落的id")] public int Id;

    /// <summary>
    /// 每个对话段落的所有对话节点
    /// </summary>
    [Header("每个对话段落的所有对话节点")] public DialogueNodeData[] DialogueNodes;
}

[Serializable]
public class DialogueNodeData
{
    /// <summary>
    /// 对话Id(每段对话里的第一句话的Id需要默认给0)
    /// </summary>
    [Header("对话Id")] public int Id = -1;

    /// <summary>
    /// 下一句对话的Id(-1就是最后一句话)
    /// </summary>
    [Header("下一句对话的Id")] public int NextId = -1;

    /// <summary>
    /// 对话限制
    /// </summary>
    [Header("对话限制")] public DialogueConditionData[] DialogueConditionDatas;
    
    /// <summary>
    /// 对话背景的Url
    /// </summary>
    [Header("对话背景的Url(包括CG)")]public string BackgroundUrl;

    /// <summary>
    /// 对话内容
    /// </summary>
    [Header("对话内容")]public string DialogueContent;

    /// <summary>
    /// 对话角色
    /// </summary>
    [Header("对话的角色名字")]public string DialogueCharacterName;

    /// <summary>
    /// 对话角色的头像Url
    /// </summary>
    [Header("对话的角色头像Url")]public string DialogueCharacterUrl;

    /// <summary>
    /// 选择结束后调用的事件
    /// </summary>
    [Header("选择结束后调用的事件")] public EventHandle[] EventHandles;

    /// <summary>
    /// 每段对话的选项
    /// </summary>
    [Header("每段对话的选项")]public DialogueOptionData[] DialogueOptions;
}

[Serializable]
public class DialogueOptionData
{
    /// <summary>
    /// 选择文本
    /// </summary>
    [Header("选择文本")] public string optionText;

    /// <summary>
    /// 指向的下一个DialogueNode的id
    /// </summary>
    [Header("选择后指向的对话id")] public int nextNodeId;

    /// <summary>
    /// 选择结束后调用的事件
    /// </summary>
    [Header("选择结束后调用的事件")] public EventHandle[] EventHandles;
}

[Serializable]
public class DialogueConditionData
{
    [Header("对话限制条件Enum")] public DialogueConditionEnum DialogueConditionEnum;

    [Header("对话限制具体数值")] public int Value;
}

2.2 状态机驱动的对话控制器

对话流程通过状态机管理,确保逻辑清晰:

using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace DialogueSystem
{
    public class DialogueController : Singleton<DialogueController>
    {
        /// <summary>
        /// 对话集
        /// </summary>
        private DialoguePortfolio _dialoguePortfolio;

        /// <summary>
        /// 目前的对话节点的顺序
        /// </summary>
        private int _nowDialogueNodeId = -1;

        /// <summary>
        /// 正在进行的段落对话
        /// </summary>
        private DialogueGroup _nowDialogueGroup;

        #region 状态机

        // 状态机替代布尔标志
        public enum DialogueState
        {
            Inactive,
            Printing,
            WaitingInput,
            WaitingChoice
        }

        public DialogueState CurrentState = DialogueState.Inactive;

        /// <summary>
        /// 切换对话状态机
        /// </summary>
        private void TryChangeState(DialogueState newState)
        {
            if(CanTransitionTo(newState)) 
            {
                CurrentState = newState;
            }
            else
            {
                Debug.LogError("请查看方法 CanTransitionTo 状态机转换规则");
            }
        }
        
        private bool CanTransitionTo(DialogueState newState)
        {
            // 添加状态转换规则
            // 例如:不能从WaitingChoice直接跳到Printing
            if (CurrentState == DialogueState.WaitingChoice && newState == DialogueState.Printing)
            {
                return false;
            }
            else
            {
                return true;
            }
        }

        #endregion

        #region 事件

        /// <summary>
        /// 对话响应事件
        /// </summary>
        public Action<string, string, string, string> DialogueHandle;

        /// <summary>
        /// 跳过文字逐渐显示事件
        /// </summary>
        public Action<string> SkipTextGraduallyShowHandle;

        /// <summary>
        /// 对话选择选项事件
        /// </summary>
        public Action<DialogueOption[]> SelectOptionHandle;

        #endregion

        /// <summary>
        /// 用于取消文本显示
        /// </summary>
        private CancellationTokenSource _textDisplayCTS;

        /// <summary>
        /// 打字机显示文字的时间
        /// </summary>
        public float ShowTextSpeed => 0.05f;

        /// <summary>
        /// 开始对话或者继续对话(根据是否正在对话来判断)
        /// </summary>
        public void StartOrContinueDialogue(int groupId = -1, bool isSelectOption = false)
        {
            switch (CurrentState)
            {
                case DialogueState.Inactive when groupId == -1:
                    Debug.LogError("首次启动对话必须提供Group ID");
                    return;

                case DialogueState.Inactive:
                    StartNewDialogueGroup(groupId);
                    break;

                case DialogueState.Printing:
                    SkipCurrentTextPrinting();
                    break;

                case DialogueState.WaitingInput:
                    ContinueToNextNode(groupId, isSelectOption);
                    break;

                case DialogueState.WaitingChoice:
                    // 选项选择中不响应继续请求
                    break;
            }
        }

        /// <summary>
        /// 开始对话
        /// </summary>
        private void StartNewDialogueGroup(int groupId)
        {
            UIManager.Instance.Show(DialoguePanel);
            _nowDialogueGroup = _dialoguePortfolio.GetDialogueGroup(groupId);
            MoveToNode(0);
        }

        /// <summary>
        /// 移动到下一个对话节点
        /// </summary>
        private void MoveToNode(int nodeId)
        {
            _nowDialogueNodeId = nodeId;

            // 是否结束对话
            if (!_nowDialogueGroup.Id_DialogueNode.TryGetValue(nodeId, out var node))
            {
                if (nodeId == -1) // 如果Id为-1,则默认结束对话
                {
                    EndDialogue();
                }
                else
                {
                    Debug.LogError($"该对话Id:<{nodeId}>找不到对应的对话配置,请检查");
                }
                return;
            }
            
            // 对话限制检测
            if (node.DialogueConditionEnum_IDC != null)
            {
                foreach (KeyValuePair<DialogueConditionEnum,IDialogueCondition> keyValue in node.DialogueConditionEnum_IDC)
                {
                    IDialogueCondition iDialogueCondition = keyValue.Value;
                    // 检查是否符合条件
                    if (!iDialogueCondition.CheckCondition())
                    {
                        // 若不符合条件则自动递归跳转到下一句对话
                        Debug.LogWarning($"由于不符合条件,故跳过对话 <{_nowDialogueGroup.Id}:{nodeId}>");
                        MoveToNode(node.NextId);
                        return;
                    }
                }
            }

            // 触发对话显示
            DialogueHandle?.Invoke(
                node.BackgroundUrl,
                node.CharacterName,
                node.CharacterHeadUrl,
                node.Content
            );

            // 处理选项
            if (node.DialogueOptions != null && node.DialogueOptions.Length > 0)
            {
                TryChangeState(DialogueState.WaitingChoice);
                SelectOptionHandle?.Invoke(node.DialogueOptions);
                return;
            }

            // 开始文本显示
            StartTextDisplay(node.Content);
        }

        /// <summary>
        /// 开始文本显示
        /// </summary>
        private void StartTextDisplay(string content)
        {
            TryChangeState(DialogueState.Printing);
            // 取消之前的显示任务
            _textDisplayCTS?.Cancel();
            _textDisplayCTS = new CancellationTokenSource();

            JudgeShowText(content, _textDisplayCTS.Token).Forget();
        }

        /// <summary>
        /// 再次输入跳过文字打印
        /// </summary>
        private void SkipCurrentTextPrinting()
        {
            _textDisplayCTS?.Cancel();
            SkipTextGraduallyShowHandle?.Invoke(_nowDialogueGroup
                .GetDialogueNode(_nowDialogueNodeId).Content);
            CompleteTextDisplay();
        }

        /// <summary>
        /// 开始计算文字是否显示完
        /// </summary>
        private async UniTask JudgeShowText(string content, CancellationToken token)
        {
            try
            {
                // 计算显示时间(最小0.5秒保证体验)
                float displayTime = Mathf.Max(0, ShowTextSpeed * content.Length);
                // await UniTask.Delay(TimeSpan.FromSeconds(displayTime), cancellationToken: token);
                await UniTask.WaitForSeconds(displayTime);
                if (!token.IsCancellationRequested)
                {
                    CompleteTextDisplay();
                }
            }
            catch (OperationCanceledException)
            {
                // 正常取消,无需处理
            }
        }

        /// <summary>
        /// 完成文字打印并显示
        /// </summary>
        private void CompleteTextDisplay()
        {
            TryChangeState(DialogueState.WaitingInput);
            _nowDialogueGroup.GetDialogueNode(_nowDialogueNodeId)?.OnFinishEvent?.Invoke();
        }

        /// <summary>
        /// 继续下一句对话
        /// </summary>
        private void ContinueToNextNode(int groupId, bool isSelectOption)
        {
            var currentNode = _nowDialogueGroup.GetDialogueNode(_nowDialogueNodeId);
            int nextNodeId = isSelectOption ? groupId : currentNode.NextId;
            MoveToNode(nextNodeId);
        }

        /// <summary>
        /// 选择了某个选项
        /// </summary>
        /// <param name="dialogueOption"></param>
        public void SelectOneOption(DialogueOption dialogueOption)
        {
            if (CurrentState != DialogueState.WaitingChoice) return;
            if (dialogueOption == null) return;
            dialogueOption.OnSelectEvent?.Invoke();
            TryChangeState(DialogueState.WaitingInput);
            StartOrContinueDialogue(dialogueOption.NextNodeId, true);
        }

        /// <summary>
        /// 结束对话
        /// </summary>
        private void EndDialogue()
        {
            // 记录已完成对话
            SaveProgress(_nowDialogueGroup.Id, _nowDialogueNodeId);

            // 恢复对话状态
            TryChangeState(DialogueState.Inactive);
            _nowDialogueNodeId = -1;
            _nowDialogueGroup = null;
            UIManager.Instance.Close(DialoguePanel);

            // 清理资源
            _textDisplayCTS?.Cancel();
            _textDisplayCTS = null;
        }

        // 记录已读对话
        private void SaveProgress(int groupId, int nodeId)
        {
            PlayerPrefs.SetInt("LastGroupId", groupId);
            PlayerPrefs.SetInt("LastNodeId", nodeId);
            // 可扩展保存已读选项等
        }

        private void OnDestroy()
        {
            // 清理静态事件
            DialogueHandle = null;
            SkipTextGraduallyShowHandle = null;
            SelectOptionHandle = null;

            // 清理任务
            _textDisplayCTS?.Cancel();

            if (Instance == this)
            {
                Instance = null;
            }
        }
    }
}

using DialogueSystem;
using UnityEngine;

public class PlayerInputController : MonoBehaviour
{
    /// <summary>
    /// 防误触
    /// </summary>
    private float _clickTimer = 0;
    private void Update()
    {
        _clickTimer += Time.deltaTime;
        if (_clickTimer > 0.1f)
        {
            if (Input.GetKeyDown(KeyCode.Space) || Input.GetMouseButtonDown(0))
            {
                _clickTimer = 0;
                //该判定之后可集成至状态机内,但仍需该类集中控制玩家输出,这样既可让各输出相关的模块与该类实现解耦,也可方便该类集中控制玩家操作
                if (DialogueController.Instance.CurrentState != DialogueController.DialogueState.Inactive) // 是否正在对话
                {
                    DialogueController.Instance.StartOrContinueDialogue();
                }
            }
        }

    }
}

2.3 条件检测系统

实现灵活的条件分支(在RPG游戏中不是经常会出现拥有某个角色或者完成某条件才会出现的对话么)机制:

public interface IDialogueCondition
{
    bool CheckCondition(params int[] values);
}

public class CharacterLimit : DialogueCondition
{
    private int _characterId;
    
    public override bool CheckCondition()
    {
        return CardController.Instance.IsHasCardByIdInTeam(_characterId);
    }
}

// 在对话节点中应用条件
if (node.DialogueConditionEnum_IDC != null)
{
    foreach (var condition in node.DialogueConditionEnum_IDC)
    {
        if (!condition.Value.CheckCondition())
        {
            MoveToNode(node.NextId); // 条件不满足时跳过
            return;
        }
    }
}

2.4 UI表现层优化

文本逐字显示与跳过
private async UniTask ShowTextGradually(string contentText, float speed)
{
    StringBuilder sb = new StringBuilder();
    _contentText.text = "";
    
    foreach (char letter in contentText.ToCharArray())
    {
        sb.Append(letter);
        _contentText.text = sb.ToString();
        await UniTask.WaitForSeconds(speed);
    }
}

// 跳过显示
private void SkipShowTextGradually(string content)
{
    _cancellationTokenSource?.Cancel();
    _contentText.text = content;
}
选项对象池

使用对象池优化选项按钮的创建与销毁:

private ObjectPool<Transform> _selectPool;

void Awake()
{
    _selectPool = new ObjectPool<Transform>(
        createFunc: () => Instantiate(_selectButton, _selectContent),
        actionOnGet: node => node.gameObject.SetActive(true),
        actionOnRelease: node => node.gameObject.SetActive(false),
        actionOnDestroy: node => Destroy(node.gameObject)
    );
}

private void ShowSelectOption(DialogueOption[] dialogueOptions)
{
    // 回收旧选项
    foreach (var node in _selectNodes)
    {
        _selectPool.Release(node);
    }
    
    // 生成新选项
    for (int i = 0; i < dialogueOptions.Length; i++)
    {
        Transform selectTran = _selectPool.Get();
        // 配置选项按钮...
    }
}

3. 系统优化设计

3.1事件回调机制

对话节点和选项都支持事件回调:

// 对话节点事件
dialogueNode.AddTalkedEvent(() => {
    GlobalEvent.Call(eventHandle.GlobeEventEnum, eventHandle.Value);
});

// 选项事件
option.AddOnSelectEvent(() => {
    GlobalEvent.Call(eventHandle.GlobeEventEnum, eventHandle.Value);
});

3.2 资源加载优化

使用缓存机制避免重复加载资源(以及资源加载方法拓展,检测和异步加载资源):

private Dictionary<string, Texture> _bgUrl_texture = new();

private async UniTaskVoid RefreshDialogueUI(string bgUrl)
{
    if (_cacheBgUrl != bgUrl) 
    {
        _cacheBgUrl = bgUrl;
        
        if (!_bgUrl_texture.TryGetValue(bgUrl, out Texture texture))
        {
            texture = new Resources.SafeLoadAsync<Texture>($"Background/{bgUrl}");
            _bgUrl_texture.Add(bgUrl, texture);
        }
        
        _bg.texture = texture;
    }
}

// 资源加载拓展
public static class ResourcesExtend
{
    public static async UniTask<T> SafeLoadAsync<T>(this Resources _, string path) where T : Object
    {
        ResourceRequest request = Resources.LoadAsync<T>(path);
        await request;
        if (request.asset == null)
        {
            Debug.LogWarning($"{path} 该资源路径没有对应的资源显示,请检查");
        }
        return (T) request.asset;
    }
}

3.3 对话进度保存

简单的进度保存实现:(可用来进行做文本储存器以及已读文本对应的功能)

private void SaveProgress(int groupId, int nodeId)
{
    PlayerPrefs.SetInt("LastGroupId", groupId);
    PlayerPrefs.SetInt("LastNodeId", nodeId);
}

4.使用案例

4.1 启动对话

// 启动ID为1的对话组
DialogueController.Instance.StartOrContinueDialogue(1);

4.2 配置对话节点

在Inspector中配置对话节点:

  1. 设置对话内容、角色信息

  2. 配置条件限制(如角色持有特定卡牌)

  3. 添加选项分支,选项事件

  4. 设置对话结束事件

4.3 演示

对话系统与走格子系统以及音频管理的运用

 5. 总结

本文介绍的Unity对话系统具有以下特点:

  • 模块化设计:各功能模块职责分明

  • 状态机驱动:确保对话流程清晰可控

  • 条件分支:支持复杂的剧情分支

  • 性能优化:对象池和资源缓存提升运行效率

  • 易扩展性:可轻松添加新的条件类型和事件

性能优化点

  • 对象池应用:选项按钮复用减少GC

  • 资源缓存:避免重复加载相同背景

  • 异步操作:使用UniTask处理文本显示

  • 状态机管理:确保状态转换高效可靠

补充说明(以及未完善功能)

1. 语音系统集成

  • 为对话节点添加语音字段

  • // 配合AudioManager使用
    [Header("语音资源")] 
    public AudioClip voiceClip;

 2. 动画支持

  • 添加角色表情/动作支持(可区分2D与3D<介于Spine/序列帧动画>)

    [Serializable]
    public class CharacterAnimation {
        public string emotion;
        public string gesture;
    }

3. 对话历史记录 

  • 实现玩家可回溯的对话历史功能

public class DialogueHistory {
    public List<DialogueRecord> records = new();
    
    public void AddRecord(string speaker, string content) {
        // 存储对话记录
    }
}

 4. 本地化优化

  • 构建键值对翻译系统(如系统成体系可直接使用配置表切换语言系统)

public string GetLocalizedContent(string key) {
    return LocalizationManager.GetTranslation(key);
}

5. 过渡动画 

  • 还没想好具体数据结构和实现方案,但留个记号在这里,哈哈

Github

之前红点的源码(由于暂时不打算动,所以直接发布成unitypackage了)

前缀树红点系统https://github.com/Natto986/RedPointTreeSystem

这期的对话系统咱之后还打算再修改修改,所以就先不上传git啦