群组行为:
模拟鸟群行走或人群行走过程的称之为群组行为
分散 、队列 、 聚集
分散:在群体内,个体必须与其他个体之间保持一定的距离,当小于那个距离时,就要分散开来。
实现原理:确定需要分散的范围separationDistance,获取在该范围内的个体,计算出当前个体与(当前个体的周围个体)的相反作用力的合力,即 合力 += (当前个体位置-其他个体位置);
但是,由于我们是想要当前个体位置与其他个体位置的距离越小,产生的相反作用力越大,所以 相反作用力= (当前个体位置 -其他个体位置).normalized / (当前个体位置-其他个体位置).magnitude ,这样就能够距离越小,产生的相反作用力越大,反之越小。(注意:个人之间的距离不能小于1,不然会发生异常,也就是除数会无穷小!导致分散力无穷大!)
相反作用力的总体意思就是 当前个体位置-其他个体位置,即得到一个 其他个体位置 指向 当前个体位置的 三维向量
这个相加之后的力就是分散合力,至于我上面说的当距离越小,作用力越大是根据实际需求来决定的。
这样,我们得到了一个分散合力
队列:在群体内,个体的朝向必须保持与队列朝向一致
实现原理:
队列朝向的获取:队列指的是 当前个体的周围的个体的平均朝向,那么我们一样根据上面的相同的原理获取到周围个体,然后周围个体的朝向就是transform.forward 将这些周围个体朝向 全部相加起来,再求平均,即得到了队列朝向。
而我们想要的目的是 将当前朝向 逐渐 朝向 队列朝向。那么我们需要加一个力,这个力叫队列趋向力
我们将队列朝向向量-当前个体朝向向量,当前个体朝向向量是transform.forward,这样获取到一个队列趋向力,为什么会要这样算?因为队列朝向向量-当前个体朝向向量是根据物理上的四边形原则,即合力、分力的一个原理来得出的。如果我们只是将队列朝向向量当做队列趋向力的话,这样变化幅度很小,而我们直接计算出一个能够让当前个体受力变为队列朝向的分力,变化幅度就很大。若还是不理解的话,可以看下物理的合力、分力的解释以及四边形原则,在这里 队列朝向就是合力,当前个体原本的受力和队列趋向力都是分力,2个分力合起来就是一个合力,即 当前个体原本的受力(当前朝向向量)+队列趋向力 = 队列朝向向量 ,这样我们也能推导出: 队列趋向力=队列朝向向量-当前朝向向量。
这样,我们就得到了一个队列趋向力
聚集:与分散相反原理。
我们在分散不发生的情况下才产生聚集,一样地获取周围个体,但是我们不是计算出聚集合力什么的,而是计算出周围个体的中心点,中心点计算方法:周围个体的位置之和/周围个体数量
然后,中心点-当前个体位置=当前个体位置 朝向 中心点 的向量,然后这个向量就是我们的聚集力
如果对聚集力的大小有点不满意的话,推荐:将聚集力的大小设置为当前速度的大小
经过以上说明,我们得到了三个力:分散合力、队列趋向力、聚集力,这三个力都可以分别设置一个权重,来调整它们的影响,加权重的意思就是给力乘以一个值
然后,我们将这三个力相加得到了一个 最终合力,我们是用这个最终合力来影响当前物体的
根据牛顿第二定律:F=ma , 故 a= F/m (F是最终合力,a是加速度(m/s^2) m是质量(kg) )
所以,在Update里面我们可以通过这个公式计算出 最终合力对物体产生的加速度a
当前速度+=a*Time.deltaTime 这样处理加速度对当前速度的影响,当前速度是一个三维向量
我们通过使用Quaternion.Slerp(transform.rotation,目标四元数, 变化系数)来逐渐旋转当前个体的朝向,面向当前速度的朝向,目标四元数用Quaternion.LookRotation(当前速度)来获取。
移动的话可以用Translate 直接朝向当前个体的前方forward来移动,速度大小为当前速度大小
(2019/05/06更新)在以上基础上,新增2个力:移动恒力(可选)、目标趋向力(可选)。
移动恒力 = 当前速度.normalized * 初始速度大小(用户自定义速度大小)
目标趋向力 = (自身指向目标向量.normalized - 当前朝向(transform.forward)) * 目标趋向速度(用户自定义速度大小)
注意:当前速度是一个三维向量Vector3,当前朝向是transform.forward,请勿以为当前速度就是当前朝向,当前速度会影响当前朝向。
FSM有限状态机:
个人理解:FSM就是一个控制状态切换的集中处理器,用一个FSMSystem管理着所有FSMState,每个FSMState(状态)都有它自身对应的转换事件(可有多个)(Transition(枚举))。
FSM根据传递进来的Transition信息,将当前状态转换为其它状态,这个转换成功条件必须是:当前状态中存在该转换事件Transition,并且转换事件对应的状态ID存在于FSM管理器中,这样才会发生转换。
每次转换时,都会触发(当前状态离开事件)和(转换后的当前状态进入事件),每个状态都有进入事件和离开事件。
注意:当前状态必须存在转换条件,并且该转换条件对应的状态必须存在于FSM管理器中!
例如:StartState、RunState、EndState 三个状态存在于FSM管理器
StartState中注册了Transition.GoToRunState转换条件,该转换条件对应的State是RunState,由于我们的状态是通过字典(键值对形式)保存在FSM状态机中的,所以我们还要有一个枚举StateID来标识每种状态,而转换条件对应的就是StateID了,那么上面的RunState的StateID属性为StateID.Run。这样StartState就存在了一个转换条件,这个转换条件也有了对应的StateID,我们把这个放入字典保存起来,key是Transition,value是StateID。
而在游戏一开始默认是StartState,那么我们就应该要有一个初始化状态机的当前状态的方法,将当前状态设置为StartState,ID为StateID.Start, 并且调用StartState的进入事件,我们状态必须要重写进入事件和离开事件,因为使得每种状态的不同就是它们的作用,不然FSM将毫无意义。
在我们给FSM状态机一个转换消息时,例如:Transition.GoToRunState,那么FSM会这样做:
1.从当前状态(StartState)中,寻找该转换条件GoToRunState对应的状态ID即StateID.Run
2.拿到StateID后,我们在FSM中保存的状态字典内 通过StateID寻找出对应的状态,即RunState
3.找到后,先调用 当前状态的离开事件(当前状态是StartState),然后将当前状态转为RunState,ID也要变,然后调用当前状态的进入事件(当前状态是RunState)。
补充:
Act事件,它是每个状态必须重写的事件,会在FSM状态机的Update中调用,FSM状态机只会调用当前状态的Act事件,Act事件相当于状态运行中的各种行为。
Reason事件,它是每个状态必须重写的事件,同样会在FSM状态机的Update调用,与Act的行为区别是它集中处理了各种转换事件的发生,就是说在什么情况下会触发什么转换条件,从而让FSM状态机自己去管理自己的状态转变。
但是,当FSM状态机不继承于Monobeviour时,Act事件和Reason事件也可能并非由状态机自身去调用,而是由创建状态机的那个脚本的Update去调用状态机的Update方法。注意,状态机不继承于MonoBehaviour的时候是这样用的,当状态机继承于Monobehaviour就不需要创建状态机自身了。
至于状态机继不继承Monobehaviour,可以自己看着办,个人认为继承于Monobehaviour不太好。
感知系统:
视觉:
1、全方位无死角视觉,实现原理:利用Vector3.Distance()判断距离是否在视觉范围内。(不常用)
2、半圆弧视觉:利用Vector3.Distance()判断距离是否在视觉范围内,并且用Vectoe3.Angle(机器人前方,从机器人指向玩家的向量)得到夹角(<90°),判断这个夹角是否在视觉角度/2内,若在视觉角度/2内,则认为是看到了,否则反之。半圆弧视觉和全方位无死角视觉的区别就在于多了一个Angle判断。这个视觉看不到看人 还会受到障碍物影响,在确认玩家在视觉范围内时,还要发射一条射线判断是否有障碍物挡着,若射线检测到的是玩家,则表明玩家在视野内,若碰到的是障碍物则不会看到玩家,也就不会触发看到玩家后的操作了。
听觉:
1、玩家跑动Bool变量,当玩家跑动时,为true,否则为false
2、听觉最大距离:利用Unity寻路系统Nav,将2个点之间的最短距离计算出,然后判断这个最短距离是否 小于 听觉最大距离,若小于,则去判断玩家跑动Bool变量是否为true,若为true就表示听到了。这2点的意思是 机器人位置和玩家位置,因为我们的听觉是会受到障碍物干扰的,所以在计算2点距离的时候就不能使用简单的Vector3.Distance了,而是使用导航系统Nav的计算方法,或者根据你自己的寻路系统方法(如A*算法)来计算2点的(受障碍物影响的)最短路径。
根据Nav寻路系统获取两个坐标点的最短路径长度值代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class NavPath : MonoBehaviour {
private NavMeshAgent agent;
public Transform end;
private void Start()
{
agent = GetComponent<NavMeshAgent>();
DrawPath();
}
private void DrawPath()
{
float smallestLength=0;
List<Vector3> path = GetPath(end.position);
Vector3[] pathArray = path.ToArray();
for(int i=0;i<pathArray.Length-1;i++)
{
GameObject go= GameObject.CreatePrimitive(PrimitiveType.Sphere);
go.transform.position = pathArray[i];
smallestLength += Vector3.Distance(pathArray[i], pathArray[i + 1]);
}
Debug.Log("路径长度:" + smallestLength);
}
private List<Vector3> GetPath(Vector3 endPoint)
{
//agent.SetDestination(endPoint);
NavMeshPath navMeshPath = new NavMeshPath();
agent.CalculatePath(endPoint, navMeshPath);
List<Vector3> path = new List<Vector3>();
for(int i=0;i<navMeshPath.corners.Length;i++)
{
path.Add(navMeshPath.corners[i]);
Debug.Log(i+1+"号 "+navMeshPath.corners[i]);
}
path.Insert(0, transform.position);
path.Add(endPoint);
return path;
}
}
A*寻路:
1、制作虚拟地图(美工管的),虚拟地图一般是用(一个类class或者结构体struct)去描述地图上一个点的信息,例如:
一个点需要有 坐标x,y ,是否为障碍物bool标志位,父节点,F,G,H,(基本都要这些)
2、讲解A*寻路的一个方程式:F=G+H
简单地说明一下,F越小越好,代表着寻找出的路径会比较短,而不是最短。其中,G是确定的,H是预判的。
该结点G的计算方法= 父节点G值 + 父节点到达该结点的距离(直接用Vector3.Distance计算出)
H值的计算方法可以有多种:例如:第一种是直接 是 该结点与终点的X之差的绝对值与Y之差的绝对值 之和。
第二种是 直接Vector3.Distance计算出 该结点与终点的距离。
其他的方法也有 ,自己想到什么是什么,这个会影响到最终寻找出的路径。
这里,因为H值的计算方法是人为设定的,可能靠谱可能不靠谱,故F值也不一定是最完美的,所以A*算法计算出的路径也不一定是最短的,而只能说是比较短的。
3、A*算法需要的5个东西,上面已经说了1个,虚拟地图map[,] 我们通过把2D/3D的物体虚拟化为一张地图,这张地图的每个点都是一个信息体,即Point,里面记录着一些信息,初始化虚拟地图的时候 我们只会将x,y值和障碍物bool初始化。
开启列表、闭合列表
开启列表存放了还未处理的,但是已经确定父节点的结点。
闭合列表存放了已经经过处理了的结点。
当一个结点被处理完了,就会进入闭合结点。
步骤:1、将起点放入开启列表。
2、若开启列表不为空,则从开启列表寻找一个结点,它的F值是最小的,开始处理该结点,我们称之为当前结点
3、判断终点是否已经在开启列表中了,若在开启列表中了,则表示找到寻路结束了,跳出循环。
3、获取 在 2 找出的拥有最小值F的结点 的 周围结点。(必须是获取能到达的结点,过滤掉越界的,障碍物的)
4、遍历3获取到的周围结点,
4.1 若发现遍历到的结点 已存在于开启列表中,也就是已经有(旧)父节点,计算出该结点新的G值,计算方法:当前结点的G值+当前结点到该结点的距离=新的G值, 用该新的G值对比 原本的G值,若新的G值更小,那么更新该结点的父节点为当前结点,G值为新的G值(更小),F=G+H(变小了)。 若新的G值比原本的要大,或者等于,就不作处理了。
4.2 若发现遍历到的结点 不存在于开启列表,我们就要计算该结点的信息,父节点=当前结点,G=当前结点的G值+当前结点到该结点的距离,H根据自己的公式计算,F=G+H,并且加入开启列表中。
5、回到2 继续执行
下面是我啰嗦的话,不明白上面一些事情的话请看看。
解释一下为什么终点存在于开列表中就代表寻路结束了,假设我们遍历到了终点之前的一个结点,那么这个结点肯定是在开启列表中F值最小的那个,对吧。还记得我们是怎么找到下一个要处理的结点的吗?是在开启列表中找出F值最小的那个结点!所以理所当然地 终点之前的一个结点是最好的,那么在该结点下 直接遍历周围结点,肯定会找到终点的,那么这样终点就会进入开启列表,终点的父节点和F、G、H值已经计算出来了,并且在开启列表中绝对是最小的,那么理所当然下一个处理的结点就是终点了,但是终点我们是不需要处理的,我们不需要继续处理了,所以循环结束,寻路结束。
这样,我们可以通过parent来获得一条路径,就是从终点不断地找parent,直至空为止。
上面,说了那么多,我说一句,寻路系统一般不用我们纯手写的,这样会不太方便,我们开发中一般会直接使用
1、Unity自带的Navigation导航系统
2、使用插件使用A* Pathfinding Project
https://www.assetstore.unity3d.com/cn/#!/content/87744
行为树:
Unity BehaviorDesigner行为树插件学习
行为树插件的学习