Unity对话系统和任务系统笔记

功能需求

我想要实现的功能有:

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的对话数据,导致当前对话变成下一个任务阶段的

那就在回复时记下要触发任务,这段对话结束后再开启任务。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值