插件的基础功能前面都已经制作完毕,现在该在实际项目中应用一下了。
准备示例场景
添加基础交互UI
演绎故事剧情需要一个基础UI界面,包含以下元素:
- 开始按钮:玩家点击后开始演绎故事剧情
- 结束面板:提示玩家剧情已结束,点击按钮重置UI
- 对话面板:展示发言者的姓名和发言的内容
- 分支面板:展示多个分支按钮,玩家点击按钮可以进入不同的剧情线
PS:以下内容均为示例,可根据自己项目的实际需要做出变动。
具体步骤如下:
- 创建文件夹:Assets/E Tool/E Story/Example/Scenes
- 打开Scenes文件夹,创建新场景,命名为
Example - 打开Example场景,创建UI画布,命名为
Canvas,与此同时系统会自动创建一个EventSystem - 再创建一个空对象,命名为
Story Manager,将Canvas移为其子对象 - 在
Canvas内依次创建开始按钮Btn Start、结束面板Pan End、对话面板Pan Dialogue和分支面板Pan Branchs,具体见下图
添加通用脚本
- 创建文件夹:Assets/E Tool/E Story/Runtime/Scripts/Common
- 打开Common文件夹,创建C#文件,命名为
UIList - 打开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;
}
}
}- 继续创建C#文件,命名为
UIItem - 打开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();
}
}
添加组件脚本
- 创建文件夹:Assets/E Tool/E Story/Example/Scripts
- 创建文件夹:Assets/E Tool/E Story/Example/Scripts/Component
- 打开Component文件夹,创建C#文件,命名为
UIBranchList - 打开UIBranchList.cs,将内容修改如下:
C#
namespace E.Story
{
// UI选项列表
public class UIBranchList : UIList<ChoiceData>
{
// 没错这里不用写任何代码
}
}
- 继续创建C#文件,命名为
UIBranchItem - 打开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);
});
}
}
}
- 继续创建C#文件,命名为
StoryUI - 打开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);
}
}
}
- 继续创建C#文件,命名为
StoryRunner - 打开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;
}
挂载组件并赋值
将UIBranchItem.cs挂载在
Item Branch对象上,并对红框中的两个变量赋值:
将UIBranchList.cs挂载在
Pan Branchs对象上,并对红框中的两个变量赋值:
将StoryUI.cs与StoryRunner.cs挂载在
Story Manager对象上,并对红框中的14个变量赋值:
测试效果
准备示例故事
根据以下截图搭建好示例故事(保存后记得在Story Runner组件中的Current Story字段处进行引用):

最终游戏窗口效果如下:

相关链接
- 完整代码:https://gitee.com/helloestar/e-story/wikis/DevelopNotes/17
- 视频版本:制作中……


