功能需求
我想要实现的功能有:
0.玩家靠近且在前面时人物自动看向玩家;
1.发起对话时弹出对话界面,依次显示文本;
2. 播放语音;
3.对话时人物的嘴动;
4.对话时人物做一些动作;
5.可以有多种回复,有的触发任务,有的不触发;不同回复可能导致下面的不同对话;
数据结构定义
为了记录对话信息,自定义了一种asset文件,里面定义一个结构体,有对话文本、语音文件、做出动作的序号、回复的文本、跳转到的序号。
using System;
using UnityEngine;
[CreateAssetMenu(menuName ="Custom/对话资源")]
public class MyTalkAsset : ScriptableObject
{
[Serializable]
public struct Response{
public string text;
public int jumpTo;
}
[Serializable]
public struct OneWord{//一轮对话
public string text;
public AudioClip audioClip;
public AnimationClip talkingAnim;
public AnimationClip eyesTalkingAnim;
public Response[]responses;
}
public OneWord[] talkData;
}
回复可能会触发任务,如下面任务部分所说,任务都有一个目的地,在场景里,任务要记录一个场景里的游戏对象,是无法做成Asset的。把一段对话做成Asset,Asset里的变量只能指向Asset,也就无法让回复指向一个任务对象。所以整段对话做成Asset和“回复触发任务”的功能冲突。
然后我想把NPC的一句话做成Asset。但是一段话有大量NPC句子,需要把它们一个个拖进对话列表,需要看Asset的文件名知道这句话的内容,但是这句话的内容在Asset里还要再抄一次。
最终放弃了把对话做成Asset的想法。只有把整段对话做成Asset才方便一点,这又与“回复触发任务,任务是场景里的对象”冲突。
人物随着任务推进,会使用不同的对话,人物声明一个对话资源的数组和记录当前对话进度的整数:
public MyTalkAsset[] talkAssets;
public int talkIndex;
1.显示文本、文本渐显
人物脚本里写一个StartTalk(),它再调用talkManager.StartTalk();,剩下的工作交给对话管理器处理。
public void StartTalk(){//把这个人物的对话文件传给对话管理器
TalkManager talkManager=StaticMain.LoadPage(StaticMain.UIPrefabsPath+"Dialog Panel").GetComponent<TalkManager>();
RectTransform rectTransform=talkManager.transform as RectTransform;
rectTransform.offsetMin=Vector2.zero;
rectTransform.offsetMax=new Vector2(Screen.width/2,panelHeight);
talkManager.myNPC=this;
talkManager.charName.text=gameObject.name;
talkManager.StartTalk();
animator.SetBool(talkingPara,true);
}
对话管理器的开始对话:
public void StartTalk(){
rectTransform=transform as RectTransform;
rectTransform.sizeDelta=new Vector2(0,dialogPanelHeight);
rectTransform.anchoredPosition=Vector2.zero;
FindObjectOfType<PlayerInput>().enabled=false;
MyInput.Instance.enabled=false;
if(!myNPC.TryGetComponent(out audioSource)){
Debug.Log(myNPC.gameObject.name+"没添加声源!");
}
Cursor.lockState=CursorLockMode.None;
continueButton=transform.Find("Content").GetComponent<Button>();
continueButton.onClick.AddListener(ContinueTalk);
sentenceIndex=0;
UpdateTalkNew(sentenceIndex);
}
从结构体数组读取信息更新对话,比从csv文件读取全文本,还要做字符串处理方便得多:
public void UpdateTalkNew(int i){
if(i<myNPC.talkAssets[myNPC.talkIndex].talkData.Length){
MyTalkAsset.OneWord oneLine=myNPC.talkAssets[myNPC.talkIndex].talkData[i];
myNPC.headConstraint.enabled=oneLine.lookAtPlayer;
Debug.Log("对话管理器打开看向玩家!");
content.text="";
if(oneLine.audioClip){
fadeSpeed=oneLine.audioClip.length/oneLine.text.Length;//根据语音长度计算文字渐显速度
}
StartCoroutine(FadeInText(content,i));
audioSource.clip=oneLine.audioClip;
audioSource.Play();
// animator.SetBool("talking",true);
MyGameManager.Instance.UpdateAnimator(myNPC.animator,defaultTalkAnim.name,myNPC.talkAssets[myNPC.talkIndex].talkData[i].talkingAnim);//把对话状态从上一段话的对话替换成这一段的
MyGameManager.Instance.UpdateAnimator(myNPC.animator,defaultEyesTalkAnim.name,myNPC.talkAssets[myNPC.talkIndex].talkData[i].eyesTalkingAnim);
}else{//对话结束
FindObjectOfType<PlayerInput>().enabled=true;
MyInput.Instance.enabled=true;
Destroy(gameObject);//不能直接删除,还要
Cursor.lockState=CursorLockMode.Locked;//隐藏光标
if(myNPC.talkAssets[myNPC.talkIndex].updateMissionProgress){
myNPC.talkIndex=myNPC.talkAssets[myNPC.talkIndex].nextTalkIndex;
myNPC.missionTrigger.OpenMission();
}
myNPC.animator.SetBool(myNPC.talkingPara,false);//玩家提前点掉对话
}
}
协程实现
文本渐显是在百度搜索直接抄的,用协程实现的:
public IEnumerator FadeInText(Text dialogText,string text){
textFadingIn=true;
for (int i = 0; i < text.Length; i++){
dialogText.text = text.Substring(0, i + 1);
yield return new WaitForSeconds(fadeSpeed);
}
textFadingIn=false;
}
但是玩家提前点掉对话时出了问题,这个文本渐显无法被打断,和下一句话的文本渐显冲突。
动画实现
玩家靠近时人物自动看向玩家
有多种方案实现,比如1.动画系统LookAt IK;2.LookAtConstraint;3.AnimationRigging的Multi Aim Constraint。
动画系统LookAt IK方案
这个方案不用另外添加组件。
问题是NPC的对话管理器脚本TalkManager需要触发器检测玩家在面前的区域,所以对话管理器和检测触发器为了不和NPC碰撞体搞混,是不能挂在NPC根节点的,需要单独创建一个节点TalkManager,然后talkManager脚本里就不能执行OnAnimatorIK()了,只能在NPC根节点的脚本里执行。talkManager设置LookAt的权重。
public bool lookingAtPlayer=false;
public void CheckLookAtPlayer(){
if(!animator.GetBool("dead")&&lookAtPlayer){
if(!animator.GetBool(talkingPara)){//没在对话
if(CheckPlayerInSector(lookAtRange,lookAtAngle)){//玩家在面前
lookingAtPlayer=true;;
}
else{
lookingAtPlayer=false;;
}
}//不写else,对话时LookAt约束交给对话管理器控制
}
else{//人死了
lookingAtPlayer=false;
}
}
void OnAnimatorIK(){
if(lookingAtPlayer){
LookAtIK();
}
}
public void LookAtIK(){
animator.SetLookAtPosition(MyInput.Instance.player.head.position);
animator.SetLookAtWeight(1);
}
LookAtConstraint方案
初始化:包含给LookAtConstraint加source的代码
Transform head;
[ContextMenu("头约束初始化")]
void HeadConstraintInitialize(){
if(!head){
head=animator.GetBoneTransform(HumanBodyBones.Head);
}
if(!headConstraint){
head.TryGetComponent(out headConstraint);
if(!headConstraint){
headConstraint=head.gameObject.AddComponent<LookAtConstraint>();
}
headConstraint.enabled=false;
List<ConstraintSource> sources=new List<ConstraintSource>();
headConstraint.GetSources(sources);
if(sources.Count==0){
ConstraintSource constraintSource=new ConstraintSource();
constraintSource.weight=1;
constraintSource.sourceTransform=MyInput.Instance.player.head;
headConstraint.AddSource(constraintSource);
headConstraint.constraintActive=true;
}
}
}
执行:动画参数talking==false时,判断玩家在不在人物前方的扇形区域内,是就打开LookAtConstraint;否就关闭
public void NPCLookAtPlayer(){
if(!animator.GetBool("dead")&&lookAtPlayer){
if(!animator.GetBool(talkingPara)){//没在对话
if(JudgePlayerInSector(lookAtRange,lookAtAngle)){//玩家在面前
headConstraint.enabled=true;
}
else{
headConstraint.enabled=false;
}
}
}
else{//人死了
headConstraint.enabled=false;
}
}
看向玩家开启关闭的维护
没有对话时,看向玩家取决于玩家相对NPC的位置,需要在每一帧判断,对话时取决于这段对话是否开启了看向玩家,只要在对话开始时判断一次。
动画参数talking==true时LookAt约束交给对话管理器,根据这段话要不要看玩家打开或关闭LookAt约束.
后来这个出了问题。对话中途talking==false时人物没有看向玩家,对话结束后却一直看向玩家。
后来我想起来我在一个动画剪辑里加过开启关闭LookAt约束的关键帧,我把这个动画剪辑从状态机除去,问题就消失了。尽管对话中从来没进过这个状态。好像在动画状态机的任何一个动画剪辑里加了一个属性,这个属性就被动画系统接管了,代码修改它就无效。所以如果一个变量想有时用脚本控制有时用动画控制,则动画应该使用动画事件控制它,不能打关键帧,否则动画系统会完全夺取它的控制权。
相关文章
Unity的Write Defaults->从一个例子谈起 - Esfog - 博客园 (cnblogs.com)
对话时人物做一些动作
需要不同人物播放不同动画。每个人用一个Animator Override Controller,对话脚本里记录每段话要做的动作,替换默认对话动作。给这段代码打上Debug.Log(),逻辑都没问题,但是打印“没看玩家”时LookAt约束在生效。
public void NPCLookAtPlayer(){
Debug.Log(name+"检测看玩家...");
if(!animator.GetBool("dead")&&lookAtPlayer){
if(!animator.GetBool(talkingPara)){//没在对话
Debug.Log(name+"没在对话");
if(CheckPlayerInSector(lookAtRange,lookAtAngle)){//玩家在面前
headConstraint.enabled=true;Debug.Log(name+"看玩家");
}
else{
headConstraint.enabled=false;Debug.Log(name+"没看玩家");
}
}
else{
headConstraint.enabled = false;
}
}
else{//人死了
headConstraint.enabled=false;
}
}
然后我发现运行时把LookAt约束在检查器都关不掉。
对话时人物的嘴动
精确的嘴动画需要根据文本制作,要花费巨量精力。这里就反复用一个嘴动画,但需要在开始播放声音时人物开始嘴动,播放完后停止嘴动。动画状态机使用一个bool参数talking控制嘴动画开始和停止。
人物的眼睛表情
我一开始把眼部表情做在Base层,因为眼部表情和身体动作一样是定制的,嘴部动作是统一的。
但是根据我的理解,Base层其他状态不改变表情,这样播放完对话动作这个表情BlendShape应该维持原状,但是实际情况是人物的眼部表情复原了。
真正的问题是我允许玩家提前点掉对话,人物不通过hasExitTime退出对话动作,然后眼部表情没复原。
然后我决定在Eyes层做眼部表情,对话的时候暂停眨眼,提前点掉对话回到眨眼状态。先试试能不能解决眼部表情不复原的问题。
效果演示
Unity手搓的对话系统_哔哩哔哩bilibili_原神_演示
怎么判断一段话说完?靠语音、动画还是字幕?
3种方案理论上都可以。
1.是在Update()里检查audioSource.isPlaying;
2.需要根据语音定制对应长度的动画;
3.从字幕渐显的代码判断;
2的工作量最大,但也是效果最好的方法。
对话动作在动画状态机里怎么设置?是用bool还是用trigger触发?动画是否勾选Loop time?
观察了一下原神的对话系统,发现这么几种情况:
1.有的对话里人物会反复摆手,即使话说完了,要等玩家点继续才做下面的动作;
2.有的对话进行时人物会进行一个动作,说完后人物循环播放一个空闲动画,此时人物的姿态就是说话动作的最后姿态,如果玩家提前点掉,人物就中断当前动作,进入下一段话的动作;
不过第一种情况算是把摆手作为了空闲动画。总的来说原神对话系统的人物动作是:一段对话开始时人物开始做一个动作,同时开始播放语音,语音和动作的持续时间被做成一致的,
如果语音动画播放完,就保持最后一帧的姿态,同时叠加一个循环播放的空闲动画(身体的轻微摇晃);
如果玩家提前点掉,就直接开始下一段对话。
对话快进功能
允许玩家在对话还在渐显的时候点对话栏,对话立即显示完。此时如果没有回复,点对话栏是开始下一句对话,如果有回复,点对话栏没有效果。对话过程中继续按钮的回调在1.快进对话;2.下一句对话;3.无回调;3种情况间反复跳转。有两种方案:
1.继续按钮在两种情况下有两种回调,按钮的回调由程序维护。
开始渐显文本时执行
button.onClick.AddListener(SkipFadeIn);
SkipFadeIn里有停止渐显协程、显示全部文本、显示回复、移除SkipFadeIn回调
void SkipFadeIn(){
StopCoroutine(showTextIE);
content.text=currentLine.text;
ShowReply(currentLine);
continueButton.onClick.RemoveListener(SkipFadeIn);
}
如果有回复,继续按钮没有回调(通过按回复继续对话);如果没有回复,执行
button.onClick.AddListener(Continue);
Continue里有
button.onClick.RemoveListener(Continue);
button.onClick.AddListener(SkipFadeIn);
2.按钮有一种回调,根据是否正在渐显,回调执行不同的内容。通过一个bool变量标记是否正在渐显:
fadingIn=true;
for (int i = 0; i < text.Length; i++)
{
content.text = text.Substring(0, i + 1);
yield return new WaitForSeconds(fadeSpeed);
}
fadingIn=false;
任务系统
在单线关卡中,一个关卡分为几个任务阶段,一个任务完成后开启下一个,直到关卡结束。但是也不是一个任务完成后立即开启下一个任务,经常有先找一个npc对话,在对话中某句回复后开启下一个任务。
可以先确定任务分为哪些类型:对话、战斗、取得物品、到达地点、刺杀等。任务基类包括所有类型任务的共同字段:
任务内容;
任务目的地;
任务开启后要更新对话的NPC;
任务开启后相关NPC要说的对话数据;
共同方法:开启任务、完成任务。
每类任务有一些特有的字段和方法。
对话任务
任务开启后相关NPC需要记录所属的任务,玩家找ta触发对话时完成任务。
战斗任务
字段:要消灭的敌人列表;
敌人列表的每个敌人也记录自己所属的任务,每个敌人死时,把敌人移出敌人列表,然后判断列表是否为空,若是,则任务完成。
由于任务是依次触发的,一个关卡的任务的数据结构可以是1.链表,每个任务记录自己完成后下一个任务。但是这样一个关卡的任务先后顺序不直观;但是这样支持多任务分支;2.列表,一个中心任务管理器有一个任务列表,这样不支持多任务分支。然而我做了任务列表界面,也就是我一直想做多分支,这就和使用关卡流程任务列表冲突了。现在问题就是,没有一个图形化的表示多分支关卡的工具,用一连多的“链表”做多分支很混乱。所以在有合适工具前,只能先放弃做多分支。
有一个列表记录此关依次要执行的任务对象,关卡流程会清楚很多:
但是多任务分支用列表就不行了。
两种NPC
NPC分为两类:永远只能触发相同对话,对关卡没有推进的npc(简称氛围NPC);对关卡有推进,不同阶段触发不同对话的npc(简称推进NPC)。
对于推进NPC,在关卡不同阶段有不同对话,在一个任务进行中触发同一个对话(如“拜托你了”),实际上出现了完成任务和进行中的多分支。这么多对话的存储位置有几种方案。
1.用List全部记录在NPC对象上。由任务对象指定完成后NPC该说哪一段对话。这样NPC对象脚本的检查器上会存一大堆对话数据。也难以看出一段对话对应哪个任务,是触发任务的对话还是任务进行中的对话。
2.NPC上只记录一段对话数据,就是当前去找ta会触发的对话。任务对象上记录两段对话,任务触发、进行中要说的对话。当关卡进度管理器显示该触发一个任务时,任务对象把触发任务对话写入npc脚本的对话变量,任务触发后把任务进行中对话写入npc对话变量。
这样氛围npc因为没有任务系统修改ta们的对话数据,自然就一直触发同一段对话,不用修改。
3.对话数据记录在任务对象上,npc只记录关联的任务对象。这样不推进进度的npc没有关联的任务,ta们的对话就要另外处理了。
很明显2是最优方案,既防止推进进度的npc的脚本里的对话数据过多,又兼容两种npc,而且不推进进度的npc只有一段对话,给ta们声明一个一段对话的List完全是浪费。
数据结构定义
一个任务对象应有的字段有:
1.任务内容文本(可能分成任务标题和任务描述);
2.任务目的地位置;
如果想直观地标记任务目的地位置,应该用一个场景内游戏对象,那么任务类就不能继承ScriptableObject做成Asset,而应该继承Monobehavior做成Component。
一个任务对象应有的方法有:
1.开启任务(可能包括NPC、敌人、物品的生成和摆放、设置NPC可触发的对话,总之几种脚本里不确定,需要在场景里确定的函数);
2.完成任务(也可能有上述函数);
坑
协程冲突
在修改NPC位置等突变操作时我写了一个画面渐变为黑色,执行操作,再变透明的函数:
public UnityAction blackoutCallback;
[ContextMenu("画面变黑")]
public void Blackout(){
StartCoroutine(BlackoutCoroutine(blackoutCallback));
}
float fadeSpeed=.04f;
IEnumerator BlackoutCoroutine(UnityAction callback=null){
while(blackBack.color.a<1){
blackBack.color+=new Color(0,0,0,fadeSpeed);
yield return 0;
}
if(callback!=null){
callback.Invoke();
}
while(blackBack.color.a>0){
blackBack.color-=new Color(0,0,0,fadeSpeed);
yield return 0;
}
}
然后这个函数在ContextMenu调用时正常,但是完成任务调用时画面就不变透明了。NPC被正确移动了。然后在第二个while循环里加了个Debug.Log(),发现第二个while循环一直在执行,但是alpha值没有变。
然后又在第一个while循环加了个打印,发现两个while循环都在一直执行。
在协程开头加打印,发现协程被执行了两次。因为一个很笨的错误。
这说明写淡入淡出时如果以imag.color.a作为循环条件,如果在一个淡入淡出完成前开始另一个,两个协程就会打架,淡入淡出永远完不成。实际开发中如果无法避免一个淡入淡出进行中开始另一个,就根据计算好的循环次数,或者直接规定循环次数,并且循环结束后直接把alpha值写成目标值,因为如果不这么做就算循环能结束,最终的alpha会是一个半透明值。
改进后的淡入淡出:
int fadeStep=10;
IEnumerator BlackoutCoroutine(UnityAction callback=null){
for(int i=0;i<fadeStep;i++){
blackBack.color+=new Color(0,0,0,1/(float)fadeStep);
yield return 0;
}
blackBack.color=Color.black;
if(callback!=null){
callback.Invoke();
}
for(int i=0;i<fadeStep;i++){
blackBack.color-=new Color(0,0,0,1/(float)fadeStep);
yield return 0;
}
blackBack.color=new Color(0,0,0,0);
}
回复时开启任务 ,直接把下一个任务NPC要说的话写入了NPC的对话数据,导致当前对话变成下一个任务阶段的
那就在回复时记下要触发任务,这段对话结束后再开启任务。