E Story 故事编辑器开发笔记 #17 在实际项目中应用

插件的基础功能前面都已经制作完毕,现在该在实际项目中应用一下了。

准备示例场景

添加基础交互UI

演绎故事剧情需要一个基础UI界面,包含以下元素:

  • 开始按钮:玩家点击后开始演绎故事剧情
  • 结束面板:提示玩家剧情已结束,点击按钮重置UI
  • 对话面板:展示发言者的姓名和发言的内容
  • 分支面板:展示多个分支按钮,玩家点击按钮可以进入不同的剧情线

PS:以下内容均为示例,可根据自己项目的实际需要做出变动。

具体步骤如下:

  1. 创建文件夹:Assets/E Tool/E Story/Example/Scenes
  2. 打开Scenes文件夹,创建新场景,命名为Example
  3. 打开Example场景,创建UI画布,命名为Canvas,与此同时系统会自动创建一个EventSystem
  4. 再创建一个空对象,命名为Story Manager,将Canvas移为其子对象
  5. Canvas内依次创建开始按钮Btn Start、结束面板Pan End、对话面板Pan Dialogue和分支面板Pan Branchs,具体见下图

添加通用脚本

  1. 创建文件夹:Assets/E Tool/E Story/Runtime/Scripts/Common
  2. 打开Common文件夹,创建C#文件,命名为UIList
  3. 打开UIList.cs,将内容修改如下:
C#
using System.Collections.Generic;
using UnityEngine;

namespace E.Story
{
    // UI列表
    public abstract class UIList<T> : MonoBehaviour
    {
        [Tooltip("父对象")][SerializeField] protected Transform parent;
        [Tooltip("子对象预制体")][SerializeField] protected GameObject prefab;
        [Tooltip("数据列表")][SerializeField] protected List<T> datas = new();

        public List<T> Datas { get => datas; }

        protected virtual void Awake()
        {
            Refresh();
        }

        // 设置数据列表并自动刷新
        public void SetDatas(List<T> datas)
        {
            this.datas = datas;
            Refresh();
        }

        // 刷新数据列表
        public virtual void Refresh()
        {
            //将所有子对象禁用
            for (int i = 0; i < parent.childCount; i++)
            {
                parent.GetChild(i).gameObject.SetActive(false);
            }

            //检测数据列表是否为空
            if (datas == null)
                return;

            //遍历设置数据
            foreach (T item in datas)
            {
                GameObject go = GetAvailableItem();
                UIItem<T> slot = go.GetComponent<UIItem<T>>();
                slot.SetData(item);
            }
        }

        // 获取可用的子对象
        protected GameObject GetAvailableItem()
        {
            GameObject go;

            //遍历子对象列表,获取一个禁用的子对象,启用它并返回
            for (int i = 0; i < parent.childCount; i++)
            {
                if (parent.GetChild(i).gameObject.activeInHierarchy)
                    continue;

                go = parent.GetChild(i).gameObject;
                go.SetActive(true);
                return go;
            }

            //遍历不到可用的子对象时,直接新实例化一个
            go = Instantiate(prefab, parent);
            go.SetActive(true);
            return go;
        }
    }
}
  1. 继续创建C#文件,命名为UIItem
  2. 打开UIItem.cs,将内容修改如下:
C#
using UnityEngine;

namespace E.Story
{
    // UI列表子对象
    public abstract class UIItem<T> : MonoBehaviour
    {
        [Tooltip("数据")][SerializeField] private T data;

        public T Data { get => data; }

        // 设置数据并自动刷新
        public void SetData(T data)
        {
            this.data = data;
            Refresh();
        }

        // 刷新
        public abstract void Refresh();
    }
}

添加组件脚本

  1. 创建文件夹:Assets/E Tool/E Story/Example/Scripts
  2. 创建文件夹:Assets/E Tool/E Story/Example/Scripts/Component
  3. 打开Component文件夹,创建C#文件,命名为UIBranchList
  4. 打开UIBranchList.cs,将内容修改如下:
C#
namespace E.Story
{
    // UI选项列表
    public class UIBranchList : UIList<ChoiceData>
    {
        // 没错这里不用写任何代码
    }
}
  1. 继续创建C#文件,命名为UIBranchItem
  2. 打开UIBranchItem.cs,将内容修改如下:
C#
using UnityEngine;
using UnityEngine.UI;

namespace E.Story
{
    // UI选项
    public class UIBranchItem : UIItem<ChoiceData>
    {
        // 要保证父对象上有这些组件
        private StoryRunner storyRunner;
        private StoryUI storyUI;

        [Header("UI组件")]
        [SerializeField] private Button btnBranch;
        [SerializeField] private Text txtBranch;


        private void Start()
        {
            storyRunner = GetComponentInParent<StoryRunner>();
            storyUI = GetComponentInParent<StoryUI>();
        }

        public override void Refresh()
        {
            // 更新选项文本
            txtBranch.text = Data.Text;

            // 绑定点击事件
            btnBranch.onClick.RemoveAllListeners();
            btnBranch.onClick.AddListener(() =>
            {
                storyUI.HideBranchs();
                storyRunner.DoNode(Data.NextNodeID);
            });
        }
    }
}
  1. 继续创建C#文件,命名为StoryUI
  2. 打开StoryUI.cs,将内容修改如下:
C#
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

namespace E.Story
{
    // 故事UI(可根据实际项目需求修改此脚本)
    public class StoryUI : MonoBehaviour
    {
        [Header("主要组件")]
        [SerializeField] private StoryRunner storyRunner;

        [Header("UI组件")]
        /* UI画布 */
        [SerializeField] private Canvas cvsStoryUI;
        /* 菜单按钮 */
        [SerializeField] private Button btnStart;
        /* 结束面板 */
        [SerializeField] private GameObject panEnd;
        [SerializeField] private Button btnEnd;
        /* 对话面板 */
        [SerializeField] private GameObject panDialogue;
        [SerializeField] private GameObject panRoleName;
        [SerializeField] private Text txtRoleName;
        [SerializeField] private Text txtContent;
        [SerializeField] private Button btnContinue;
        /* 选项面板 */
        [SerializeField] private GameObject panBranchs;
        [SerializeField] private UIBranchList uiBranchList;

        private void Start()
        {
            // 绑定点击事件
            btnStart.onClick.AddListener(() => storyRunner.StartStory());
            btnEnd.onClick.AddListener(() => storyRunner.EndStory());
            btnContinue.onClick.AddListener(() => storyRunner.NextStep());
        }

        // 显示菜单
        public void ShowMenu()
        {
            btnStart.gameObject.SetActive(true);
        }

        // 隐藏菜单
        public void HideMenu()
        {
            btnStart.gameObject.SetActive(false);
        }

        // 显示结束面板
        public void ShowEnd()
        {
            panEnd.gameObject.SetActive(true);
        }

        // 隐藏结束面板
        public void HideEnd()
        {
            panEnd.gameObject.SetActive(false);
        }

        // 显示对话面板
        public void ShowDialogue(string rolename, SentenceData sentenceData)
        {
            panDialogue.SetActive(true);
            panRoleName.SetActive(!string.IsNullOrEmpty(rolename));

            txtRoleName.text = rolename;
            txtContent.text = sentenceData.Text;
        }

        // 隐藏对话面板
        public void HideDialogue()
        {
            panDialogue.SetActive(false);
        }

        // 显示选项面板
        public void ShowBranchs(List<ChoiceData> choiceDatas)
        {
            panBranchs.SetActive(true);
            uiBranchList.SetDatas(choiceDatas);
        }

        // 隐藏选项面板
        public void HideBranchs()
        {
            panBranchs.SetActive(false);
        }
    }
}
  1. 继续创建C#文件,命名为StoryRunner
  2. 打开StoryRunner.cs,将内容修改如下:
C#
using System.Collections.Generic;
using UnityEngine;

namespace E.Story
{
    // 故事运行器(可根据实际项目需求修改此脚本)
    public class StoryRunner : MonoBehaviour
    {
        [Header("主要组件")]
        [SerializeField] private StoryUI storyUI;

        [Header("当前故事")]
        [SerializeField] private StoryDataSO currentStory;

        [Header("运行时变量")]
        [SerializeField] private bool isStoryPlaying;
        [SerializeField] private NodeData currentNodeData;
        [SerializeField] private NodeData nextNodeData;
        [SerializeField] private int currentSentenceIndex;


        private void Start()
        {
            // 初始化UI状态
            storyUI.ShowMenu();
            storyUI.HideEnd();
            storyUI.HideDialogue();
            storyUI.HideBranchs();
        }

        // 开始故事
        public void StartStory()
        {
            if (currentStory == null)
            {
                Debug.LogError("未指定故事");
                return;
            }

            NodeData nodeData = currentStory.GetStartNode();
            if (nodeData == null)
            {
                Debug.LogError("开始节点不存在");
                return;
            }

            isStoryPlaying = true;

            storyUI.HideMenu();
            storyUI.HideEnd();
            storyUI.HideDialogue();
            storyUI.HideBranchs();

            DoNode(nodeData);
        }

        // 结束故事
        public void EndStory()
        {
            // 重置数据
            isStoryPlaying = false;
            currentNodeData = null;
            nextNodeData = null;
            currentSentenceIndex = 0;

            // 重置UI
            storyUI.ShowMenu();
            storyUI.HideEnd();
            storyUI.HideDialogue();
            storyUI.HideBranchs();
        }

        // 执行节点
        public void DoNode(string nodeID)
        {
            NodeData nodeData = currentStory.GetNode(nodeID);
            DoNode(nodeData);
        }

        // 执行节点
        public void DoNode(NodeData nodeData)
        {
            if (!isStoryPlaying)
            {
                Debug.LogError("故事未开始");
                return;
            }

            currentNodeData = nodeData;

            if (nodeData == null)
            {
                Debug.LogError("节点不存在");
                return;
            }

            // 根据当前节点类型执行对应的操作
            switch (nodeData.Type)
            {
                case NodeType.Start:
                    Debug.Log($"执行开始节点:{nodeData.Title}\n故事开始");
                    NextStep();
                    break;

                case NodeType.End:
                    Debug.Log($"执行结束节点:{nodeData.Title}\n故事结束");
                    storyUI.HideMenu();
                    storyUI.ShowEnd();
                    storyUI.HideDialogue();
                    storyUI.HideBranchs();
                    break;

                case NodeType.Dialogue:
                    Debug.Log($"执行对话节点:{nodeData.Title}");
                    ShowSentence(0);
                    break;

                case NodeType.Branch:
                    List<ChoiceData> availableChoices = GetAvailableChoices(nodeData.ChoiceDatas);
                    Debug.Log($"执行分支节点:{nodeData.Title}\n包含有效选项{availableChoices.Count}个");
                    storyUI.ShowBranchs(availableChoices);
                    break;

                default:
                    break;
            }
        }

        // 执行下个步骤
        public void NextStep()
        {
            if (!isStoryPlaying)
            {
                Debug.LogError("故事未开始");
                return;
            }

            if (currentStory == null)
            {
                Debug.LogError("未指定故事");
                return;
            }

            if (currentNodeData == null)
            {
                Debug.LogError("未指定当前节点");
                return;
            }

            nextNodeData = null;
            string nodeID;

            // 根据当前节点类型执行下一步操作
            switch (currentNodeData.Type)
            {
                // 自动执行
                case NodeType.Start:
                    nodeID = currentNodeData.ChoiceDatas[0].NextNodeID;
                    nextNodeData = currentStory.GetNode(nodeID);
                    break;

                // 停止执行
                case NodeType.End:
                    Debug.LogWarning("故事已结束,没有后续节点。");
                    return;

                // 等待执行
                case NodeType.Dialogue:
                    // 检测下个句子索引是否越界
                    int nextSentenceIndex = currentSentenceIndex + 1;
                    if (nextSentenceIndex >= currentNodeData.SentenceDatas.Count)
                    {
                        // 句子展示完毕,执行下个节点
                        nodeID = currentNodeData.ChoiceDatas[0].NextNodeID;
                        nextNodeData = currentStory.GetNode(nodeID);
                        break;
                    }
                    else
                    {
                        // 句子未展示完毕,展示下个句子
                        ShowSentence(nextSentenceIndex);
                        return;
                    }
                case NodeType.Branch:
                    Debug.LogWarning("请选择一个分支选项");
                    return;

                default:
                    break;
            }

            if (nextNodeData == null)
            {
                Debug.LogError("未连接下个节点。如果故事剧情在此结束,请连接至结束节点。");
                return;
            }

            DoNode(nextNodeData);
        }

        // 显示句子
        public void ShowSentence(int index)
        {
            NodeData node = currentNodeData;
            currentSentenceIndex = index;

            storyUI.ShowDialogue(node.RoleName, node.SentenceDatas[index]);
            Debug.Log($"显示对话:{node.RoleName}(第{index + 1}句)\n{node.SentenceDatas[index].Text}");
        }

        // 获取有效选项数据列表
        private List<ChoiceData> GetAvailableChoices(List<ChoiceData> choiceDatas)
        {
            List<ChoiceData> datas = new();
            foreach (ChoiceData choiceData in choiceDatas)
            {
                if (IsChoiceAvailable(choiceData))
                {
                    datas.Add(choiceData);
                }
            }

            return datas;
        }

        // 检测选项是否有效
        private bool IsChoiceAvailable(ChoiceData choiceData)
        {
            // 如果没设置下个节点,跳过此选项
            if (string.IsNullOrEmpty(choiceData.NextNodeID))
                return false;

            // 默认将当前选项标记为可用
            return true;
        }
    }
}

打开StoryDataSO.cs,新增以下方法:

C#
// 获取开始节点
public NodeData GetStartNode()
{
    foreach (NodeData nodeData in nodeDatas)
    {
        if (nodeData.Type == NodeType.Start)
        {
            return nodeData;
        }
    }
    return null;
}

// 获取节点
public NodeData GetNode(string nodeID)
{
    foreach (NodeData nodeData in nodeDatas)
    {
        if (nodeData.GUID == nodeID)
        {
            return nodeData;
        }
    }
    return null;
}

挂载组件并赋值

  1. 将UIBranchItem.cs挂载在Item Branch对象上,并对红框中的两个变量赋值:


  2. 将UIBranchList.cs挂载在Pan Branchs对象上,并对红框中的两个变量赋值:


  3. 将StoryUI.cs与StoryRunner.cs挂载在Story Manager对象上,并对红框中的14个变量赋值:


测试效果

准备示例故事

根据以下截图搭建好示例故事(保存后记得在Story Runner组件中的Current Story字段处进行引用):

最终游戏窗口效果如下:

相关链接

留下评论