Unity 简单RPG对话系统——龙之谷2的NPC对话系统
龙之谷2手游正式上线后不久,试玩了十几分钟(包括捏脸的5分钟),之后就再也没有打开过了。
本文章将对龙之谷2的NPC对话系统进行高仿,同时考虑到 策划可能不断修改对话内容 工具的重复利用,将部分通过编辑器模式来进行制作。
写在前边:
1.参考文章
知乎: Yumir——用128行代码实现一个文字冒险游戏
站内: 虚拟喵——Unity 编辑器扩展总结 五:数组或list集合的显示方式
2.素材使用
unity AssetStore——Unity-Chan! Model
3.根据如下游戏内截图,拆分实现步骤
正式开始
一、新建Unity工程,并设置UIPanel
- 这里为了
偷懒方便演示,使用Unity娘到场景中,并复制出两个作为NPC,每个人物模型的Perfab作为空物体**Handle的子物体
- NPC1Handle和NPC2Handle分别创建子物体SphereCollider,调整至合适大小,勾选Is Trigger,并重命名为DialogTrigger,Tag选择为NPCDialog,作为主角经过时触发对话界面的触发器
- 新建Canvas–>Panel,重命名为DialogPanel,根据个人感觉调整DialogPanel的显示区域
- Canvas目录下新建一个空物体,添加GridLayoutGroup组件,用作问答类对话加载答案选项,这里重命名为AnswerGrid
- 新建一个空物体,用来挂载接下来的对话系统控制组件,这里重命名为DialogSystem
整体的层级图如下:
二、调整项目内Player的控制
根据自己的项目情况,让场景内放置好的人物能够实现简单的移动,这里以我下载好的Unity娘为例
- MainCamera代码:ThirdPersonCamera.cs,找到FixedUpdate ()部分,取消鼠标控制摄像机(因为后边希望通过鼠标左键控制对话进度,当然有需要的同学可以分开写鼠标操作)
- Player组件中,去除用不到的组件,并新建一个DialogTriggerEvent.cs,用于控制Player和NPC相遇弹出对话框,具体代码内容稍后再写
三、逻辑代码拆分
四、对话系统控制组件和他的自定义Inspector显示
1.为场景中的对话系统控制器挂载代码(DialogSystemController.cs),控制NPC图片和姓名的显示
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;public class DialogSystemController : MonoBehaviour
{[SerializeField]public List<NPCItem> npcItemArray = new List<NPCItem>();public static DialogSystemController Instance;void Awake(){Instance = this;}
}[System.Serializable]
public class NPCItem
{[SerializeField]public string ID;[SerializeField]public Sprite icon;[SerializeField]public string name;
}
2.Assets目录下,新建Editor文件夹,新建DialogSystemEditor,依赖于DialogSystemController.cs,自定义Inspector面板显示内容
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using UnityEditorInternal;[CustomEditor(typeof(DialogSystemController))]
public class DialogSystemEditor : Editor
{private ReorderableList _npcItemArray;private void OnEnable(){_npcItemArray = new ReorderableList(serializedObject, serializedObject.FindProperty("npcItemArray"), true, true, true, true);//自定义列表名称_npcItemArray.drawHeaderCallback = (Rect rect) =>{GUI.Label(rect, "NPC Array");};//定义元素的高度_npcItemArray.elementHeight = 88;//自定义绘制列表元素_npcItemArray.drawElementCallback = (Rect rect, int index, bool selected, bool focused) =>{//根据index获取对应元素 SerializedProperty item = _npcItemArray.serializedProperty.GetArrayElementAtIndex(index);rect.height -= 4;rect.y += 2;EditorGUI.PropertyField(rect, item, new GUIContent("Index " + index));};//当删除元素时候的回调函数,实现删除元素时,有提示框跳出_npcItemArray.onRemoveCallback = (ReorderableList list) =>{if (EditorUtility.DisplayDialog("Warnning", "Do you want to remove this element?", "Remove", "Cancel")){ReorderableList.defaultBehaviours.DoRemoveButton(list);}};}public override void OnInspectorGUI(){serializedObject.Update();//自动布局绘制列表_npcItemArray.DoLayoutList();serializedObject.ApplyModifiedProperties();}
}
3.通过PropertyDrawer来绘制PlayerItem的样式,注意这是对NPCItem类的绘制,不是DialogSystemController类。同样是编辑器类,需要放在Editor文件夹下
using UnityEngine;
using UnityEditor;
using UnityEngine.UI;[CustomPropertyDrawer(typeof(NPCItem))]
public class DialogSystemDrawer : PropertyDrawer
{public override void OnGUI(Rect position, SerializedProperty property, GUIContent label){using (new EditorGUI.PropertyScope(position, label, property)){//设置属性名宽度EditorGUIUtility.labelWidth = 60;position.height = EditorGUIUtility.singleLineHeight;var iconRect = new Rect(position){width = 64,height = 64};var IDRect = new Rect(position){width = position.width - 80,x = position.x + 80};var nameRect = new Rect(IDRect){y = IDRect.y + EditorGUIUtility.singleLineHeight + 5};var iconProperty = property.FindPropertyRelative("icon");var IDProperty = property.FindPropertyRelative("ID");var nameProperty = property.FindPropertyRelative("name");iconProperty.objectReferenceValue = EditorGUI.ObjectField(iconRect, iconProperty.objectReferenceValue, typeof(Sprite), false);IDProperty.stringValue = EditorGUI.TextField(IDRect, IDProperty.displayName, IDProperty.stringValue);nameProperty.stringValue = EditorGUI.TextField(nameRect, nameProperty.displayName, nameProperty.stringValue);}}
}
效果如下(ID为场景中NPC的具体名字,Icon和Name自定义):
五、对话系统显示
1.为DialogPanel新增View代码,DialogPanelView.cs,主要任务是接收Conrtoller的代码,显示出对话系统的UI层
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;public class DialogPanelView : MonoBehaviour
{[Header("Player")]public Image PlayerImage;public Text PlayerName; public Text PlayerText;[Header("NPC")]public Image NPCImage;public Text NPCName;public Text NPCText;[Header("Panels")]public GameObject playerDialogPanel;public GameObject NPCDialogPanel;public static DialogPanelView Instance;void Awake(){Instance = this;}void Start(){PlayerImage.GetComponent<Image>(); PlayerName.GetComponent<Text>();PlayerText.GetComponent<Text>();NPCImage.GetComponent<Image>();NPCName.GetComponent<Text>();NPCText.GetComponent<Text>(); }
}
2.再次为DialogPanel新增代码,TalkWin.cs,主要任务是通过DOTween插件实现主角和NPC的对话内容显示
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using DG.Tweening;
using System;public class TalkWin : MonoBehaviour
{public int textID;public Text NPCTalkText;public Text playerTalkText;public GameObject answerGrid;public GameObject answer;private List<GameObject> answers = new List<GameObject>();public CommonTalkNode[] commonTalkNodes;public SwitchTalkNode[] switchTalkNodes;private Dictionary<int, CommonTalkNode> commonDic = new Dictionary<int, CommonTalkNode>();private Dictionary<int, SwitchTalkNode> switchDic = new Dictionary<int, SwitchTalkNode>();public static TalkWin instance;private void Awake(){instance = this;foreach (CommonTalkNode item in commonTalkNodes){commonDic.Add(item.ID, item);}foreach (SwitchTalkNode item in switchTalkNodes){switchDic.Add(item.ID, item);}}private void Start(){WhenMouseClick();GetComponent<Button>().onClick.AddListener(WhenMouseClick);}private void WhenMouseClick(){ //NPC为对话模式if (textID > 1000 && textID < 2000){UpdateTalkWinShow(commonDic[textID].NPCTalkText, commonDic[textID].playerTalkText, (float)commonDic[textID].charSpeed);textID = commonDic[textID].nextID;//Debug.Log(textID);}//NPC为问答模式else if (textID > 2000 && textID < 3000){UpdateTalkWinShow(switchDic[textID].NPCTalkText, switchDic[textID].playerTalkText, (float)switchDic[textID].charSpeed);CreateAnswerUI(switchDic[textID].switchText);GetComponent<Button>().interactable = false;}//结束对话,关闭对话Panel//BUG:KeyNotFoundException: The given key was not present in the dictionaryif (commonDic.ContainsKey(commonDic[textID].ID) && commonDic[textID].NPCID != CheckNPCID()){DialogTriggerEvent.Instance.DialogPanel.SetActive(false);}}public void WhenSwitchNodeGetAnswer(int number){textID = switchDic[textID].switchNextID[number];foreach (GameObject item in answers){Destroy(item);}GetComponent<Button>().interactable = true;WhenMouseClick();}public void UpdateTalkWinShow(string NPCTalkText, string playerTalkText, float charSpeed){ if(playerTalkText != "0") //主角说话时,开启playerPanel,关闭NPCPanel{DialogPanelView.Instance.playerDialogPanel.SetActive(true);DialogPanelView.Instance.NPCDialogPanel.SetActive(false);this.playerTalkText.text = "";this.playerTalkText.DOText(playerTalkText, charSpeed * playerTalkText.Length);}else //主角不说话时,关闭playerPanel,开启NPCPanel{DialogPanelView.Instance.playerDialogPanel.SetActive(false);DialogPanelView.Instance.NPCDialogPanel.SetActive(true);this.NPCTalkText.text = "";this.NPCTalkText.DOText(NPCTalkText, charSpeed * NPCTalkText.Length);} }public void CreateAnswerUI(string[] switchText){for (int i = 0; i < switchText.Length; i++){GameObject go = Instantiate(answer, answerGrid.transform);go.GetComponent<QuestionUI>().SetAnswerUI(switchText[i],i);answers.Add(go);}}public string CheckNPCID(){return DialogTriggerEvent.Instance.NPCSingle;}
}
/// <summary>
/// 所有句子的父类
/// </summary>
public abstract class TalkNode
{// 通过NPCID进行定位public string NPCID;// 每个句子独有的IDpublic int ID;//NPC文本public string NPCTalkText;//player文本public string playerTalkText;// 字符速度public double charSpeed;public TalkNode(string NPCID, int ID, string NPCTalkText,string playerTalkText, double charSpeed){this.NPCID = NPCID;this.ID = ID;this.NPCTalkText = NPCTalkText;this.playerTalkText = playerTalkText;this.charSpeed = charSpeed;}
}[Serializable]
public class CommonTalkNode : TalkNode
{public int nextID;public CommonTalkNode(string NPCID , int ID, string NPCTalkText, string playerTalkText, double charSpeed, int nextID) : base(NPCID , ID, NPCTalkText, playerTalkText, charSpeed){this.nextID = nextID;}
}
[Serializable]
public class SwitchTalkNode : TalkNode
{public string[] switchText;public int[] switchNextID;public SwitchTalkNode(string NPCID, int ID, string NPCTalkText, string playerTalkText, double charSpeed, string[] switchText, int[] switchNextID) : base(NPCID, ID, NPCTalkText, playerTalkText, charSpeed){this.switchText = switchText;this.switchNextID = switchNextID;}
}
3.在场景中AnswerGrid下新建Image组件,重命名为Answer,调整至合适大小,并且加入Button组件,并为他挂载新的代码QuestionUI.cs,为其添加一个Text子物体AnswerText,添加为预制体,用于显示问答对话中的答案选项
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;public class QuestionUI : MonoBehaviour
{public Text text;public int number;public void Start(){GetComponent<Button>().onClick.AddListener(()=> { TalkWin.instance.WhenSwitchNodeGetAnswer(number); });}public void SetAnswerUI(string s,int i){text.text = s;number = i;}
}
4.Player触发对话系统代码DialogTriggerEvent.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class DialogTriggerEvent : MonoBehaviour
{public GameObject DialogPanel;List<NPCItem> npcList;GameObject[] NPCgameObject;public string NPCSingle = null; //传递到TalkWinpublic static DialogTriggerEvent Instance; void Awake(){Instance = this;}void Start(){NPCgameObject = GameObject.FindGameObjectsWithTag("NPC");if(DialogSystemController.Instance.npcItemArray.Count!=0){npcList = DialogSystemController.Instance.npcItemArray;}}//进入对话区域开启Panle,结束对话关闭Panelprivate void OnTriggerEnter(Collider collider){if(collider.tag == "NPCDialog"){DialogPanel.SetActive(true);GetNPCByID(collider.transform);}}//通过当前Player触发的Trigger判断是在和哪个NPC对话public void GetNPCByID(Transform transform){for (int i = 0; i < NPCgameObject.Length; i++){Transform targetNPC = transform.parent.Find(NPCgameObject[i].name);if(targetNPC)for (int j = 0; j < npcList.Count; j++){if (npcList[j].ID == targetNPC.name){NPCSingle = targetNPC.name; //传递到TalkWin//设置当前NPC的名字和图片DialogPanelView.Instance.NPCName.text = npcList[j].name;DialogPanelView.Instance.NPCImage.sprite = npcList[j].icon;DialogPanelView.Instance.NPCImage.rectTransform.position = new Vector3(289.85f, 292, 0);} }}}
}
六、对话内容填写
1.各个代码拖入其需要的GameObject
2.DialogPanel的TalkWin代码中填写对话内容
这里需要注意几点:
-
TalkWin.cs类中包括两个Dictionary,其中 Dictionary<int, CommonTalkNode> commonDic 用于存储正常你一句我一句的对话内容,Dictionary<int, SwitchTalkNode> switchDic 用于存储问答式的对话内容
-
NPC讲话时,playerTalkText的值填写0
-
Player讲话时,NPCTalkText的值什么也不填写
-
对话结束后应跳转到一个两人都没有讲话内容的一条对话,保证在最后一次对话显示后,正常关闭对话UI
以下长图为我填写的对话内容,尤其注意ID 1006,ID 1108,ID 1201在这里是必须的
效果展示:
首先和NPC1对话
和NPC1对话完再去找NPC2
写在最后:
1.总体缺点比较明显,NPC多了再去代码的列表里修改内容,就太麻烦了,不好查改
2.只适合于和一个NPC的对话内容一次讲完的设定,如果和黑魂一样可以对话到一半走开,就不行了
3.两个Dictionary之间交换数据时unity会报错,目前还没有找到解决方案,具体BUG位置写在了TalkWin.cs中,欢迎道友指正
4.项目地址:Simple dialog system 门钥匙:hhhh