UnityUnity实现2D平台游戏带跳跃的自动寻路功能
Posted litble
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了UnityUnity实现2D平台游戏带跳跃的自动寻路功能相关的知识,希望对你有一定的参考价值。
复活之后发的第一篇博客!
问题探讨
在Unity中,你可以使用AStarPathFinder之类的包,很轻易地实现俯视角2D游戏的自动寻路。然而,实现平台游戏的自动寻路,我却没找到什么很好用的现成的工具。在苦思冥想甚久要怎么实现这个功能后,我找到了这篇博客->Here
那么开干吧!
寻路的实现
扫描地图
想要实现寻路,首先我们要读入地图。
首先,建立一个AStarNode
类,表示地图上的每一个点。类中包含以下内容:
该点在网格中的坐标(这里我保留了OI里的坏习惯,网格图行为x列为y,这样很容易与坐标轴的x和y混淆,在后续代码中我尝试用i和j来代替,但是没有全部改完……),A星算法要用到的三个参数f,g,h
,前继节点信息father
,节点类型信息type
,连边linkTarget
。
//节点类型
public enum E_Node_Type {
None,
Platform,
LeftEdge,
RightEdge,
Solo
}
//边参数,包括节点的id,和一个typeNum记录额外的信息
public class NodeId {
public int x,y;
public int typeNum;
//-1:run
//-2:drop
public NodeId(int _x, int _y, int _typeNum) {
x = _x;
y = _y;
typeNum = _typeNum;
}
}
// A*的节点
public class AStarNode
{
//网格图上的坐标
public int x,y;
public float f,g,h;
public NodeId father;
public E_Node_Type type;
public List<NodeId> linkTarget = new List<NodeId>();
public AStarNode(int _x, int _y) {
x = _x;
y = _y;
type = E_Node_Type.None;
}
public void AddLink(int x, int y, int type) {
linkTarget.Add(new NodeId(x, y, type));
}
}
节点被我分为了这些类型:
None
:空气或者障碍物,在这个例子里不加以区分
Platform
:普通的平台
LeftEdge
:某个平台的左边缘(可以从左边落下)
RightEdge
:某个平台的右边缘(可以从右边落下)
Solo
:单块平台,两边都可以落下
接下来,我们再新建一个AStarManager
类,用于管理与寻路相关的信息。第一步,我们在地图中建立一个网格,可以用Gizmos查看调整网格的宽、高和格点信息。
建立好网格后,我们要扫描每一个格点,确认格点是否是可以落脚的平台点。在这里我使用了射线检测,检测格点上下是否有碰撞。只有下方有碰撞,上方无碰撞的,才是落脚点。
至于怎么区分平台的边缘和中心,只需要对每一行格点从左往右扫描。对于没有扫描到的右侧格点,都假设其是空气。也就是说,我们通过已经得到的左侧格点信息,将新扫描出的落脚点先设为RightEdge
或是Solo
,然后再将左侧落脚点的RightEdge
和Solo
更新为Platform
和LeftEdge
。
public Vector2 GetPosition(int i, int j) {//计算格子的中心点
return new Vector2(beginX + (float)j * cellX + 0.5f, beginY + (float)i * cellY + 0.5f);
}
void PlatDefinition() {
for(int i = 0; i < mapH; ++i)
for(int j = 0; j < mapW; ++j) {
AStarNode node = new AStarNode(i, j);
Vector2 pos = GetPosition(i, j);
//只有下方有碰撞,上方无碰撞的,才是落脚点
bool upCheck = Physics2D.Raycast(pos, Vector2.up, 0.55f, layer);
bool downCheck = Physics2D.Raycast(pos, Vector2.down, 0.55f, layer);
if(downCheck && !upCheck) { //是平台
//检测是否是边缘
bool leftCheck = (j > 0 && map[i, j-1].type == E_Node_Type.None);
bool rightCheck = (j < mapW-1);
if(rightCheck && leftCheck) node.type = E_Node_Type.Solo;
else if(leftCheck) node.type = E_Node_Type.LeftEdge;
else if(rightCheck) node.type = E_Node_Type.RightEdge;
else node.type = E_Node_Type.Platform;
}
//如果左边也是平台
if(j > 0 && map[i, j-1].type != E_Node_Type.None) {
if(map[i, j-1].type == E_Node_Type.RightEdge)
map[i, j-1].type = E_Node_Type.Platform;
else map[i, j-1].type = E_Node_Type.LeftEdge;
map[i, j-1].AddLink(i, j, -1);
node.AddLink(i, j-1, -1);
//链接平移边
}
map[i, j] = node;
}
}
用Gizmos输出一下检测的效果(黄色表示边缘,红色表示非边缘):
好耶ヾ(✿゚▽゚)ノ
!
平移和坠落链接
有了点之后,就应该有连边。连边分为三种,平移,坠落和跳跃。
首先来说说平移,这个应该是非常好实现的,在扫描地图,检测边缘的时候我们就判断过当前格点的左边是否也是一个落脚点,所以在扫描的同时,我们就可以连好所有的平移边,在上一份代码中其实已经体现出来了。
然后是坠落,简化起见,我们假设坠落只发生在平台边缘,并且坠落的过程中不带横向的速度。那么我们只需要向下搜索坠落后会落到哪个点,然后进行链接即可。
//========================坠落链接=====================
void GetFallLink() {
for(int i=0; i < mapH; ++i)
for(int j=0; j < mapW; ++j) {
//可以从左边坠落
if(map[i, j].type == E_Node_Type.LeftEdge || map[i, j].type == E_Node_Type.Solo) {
if(j==0) continue;
for(int k=i-1; k >= 0; --k)
if(map[k, j-1].type != E_Node_Type.None) {
map[i, j].AddLink(k, j-1, -2);
break;
}
}
//可以从右边坠落
if(map[i, j].type == E_Node_Type.RightEdge || map[i, j].type == E_Node_Type.Solo) {
if(j==mapW-1) continue;
for(int k=i-1; k >= 0; --k)
if(map[k, j+1].type != E_Node_Type.None) {
map[i, j].AddLink(k, j+1, -2);
break;
}
}
}
}
让我们用Gizmos打印出连好的坠落边看一看吧:
跳跃链接
接下来是最难的一部分了,如何做跳跃链接。
跳跃,本质上就是画出一条抛物线,如果给定了横向和纵向的初速度,我们可以很轻松的通过初中知识计算出抛物线轨迹。
全自动的跳跃链接思路是这样的:
- 根据一定的规则,生成若干跳跃初速度的预设。
- 对于每个点,按每一个跳跃预设画出抛物线,并进行采样。
- 对每一个采样点,射线检测,或者使用类似的检测方法,检测是否会撞墙。若撞墙,则该跳跃不合法,检测下一个跳跃。
- 若在下落过程中碰到地面,则说明找到了一个跳跃的合法终点。
- 终点需要判重,还可以取消掉在同水平高度上跳跃的这种无意义行为。
另一种连边思路是,枚举两个要连跳跃边的点,然后扫描两点附近的一块地图,计算跳跃的两个参数。
采样示例(这个蓝色可能有点看不清):
不过,我最后还是使用了全手动跳跃连边方式,就是用Gizmos调参然后人为地一条条连QAQ……主要是,调参采样间距有点脑溢血……
建议跳跃连边最好能做到全自动,原因在最后一点,其他探讨里面讲。
总之,在AStarNode类里的跳跃连边方法:
//关于跳跃的参数
public class JumpParameter {
public float jumpSpeed;
public float moveSpeed;
public JumpParameter(float _v1, float _v2) {
jumpSpeed = _v1;
moveSpeed = _v2;
}
}
public class AStarNode
{
public List<JumpParameter> jumps = new List<JumpParameter>();
//使用NodeId里的typeNum参数记录跳跃类型
public void AddJumpLink(int x, int y, float v1, float v2) {
jumps.Add(new JumpParameter(v1, v2));
linkTarget.Add(new NodeId(x, y, jumps.Count - 1));
}
}
跳跃连边的效果:
A*寻路
边练好了,就可以开始在AStarManager里写Astar主体了。
AStar算法,大体上就是维护一个openList,存放待扩展的点,一个closeList,存放扩展过的点。对于每个点,维护两个数值,g表示从起点走到这个点的花费,h表示从这个点到终点的花费估值。按照f=g+h从小到大,从openList中取出一个点,扩展它能够到达的点后,将这个点扔进closeList直至搜到终点。
以下是AStar的主体:
public List<NodeId> FindPath(int startI, int startJ, int endI, int endJ) {
AStarNode start = map[startI, startJ];
AStarNode end = map[endI, endJ];
//清空openList和closeList
closeList.Clear();
openList.Clear();
//把开始点放入openList中
start.father = null;
start.f = 0;
start.g = 0;
start.h = 0;
openList.Add(start);
//寻路主体
while(openList.Count > 0) {
openList.Sort(SortOpenList);
AStarNode u = openList[0];
closeList.Add(u);
openList.RemoveAt(0);
if(u == end) {
//找到了终点,回溯
List<NodeId> path = new List<NodeId>();
AStarNode v = end;
path.Add(new NodeId(endI, endJ, -1));
while(v !=start) {
path.Add(v.father);
v = map[v.father.x, v.father.y];
}
path.Reverse();
return path;
}
FindNearlyNodeToOpenList(u, end);
}
Debug.Log("No Way!");
return null;
}
关于扩展部分。
g的计算我们利用时间,对于平移,时间就是平移距离/平移速度
。对于坠落,设定好重力加速度g
的值,通过初中物理知识计算出坠落时间。对于跳跃,由于在横向上是匀速直线运动,所以也可以轻松地算出时间。
至于f的计算,我用的是横向距离算平移,纵向距离算坠落来计算的。感觉可以优化一下,比如纵向区分一下上下,如果终点在当前点上方就需要通过跳跃抵达,肯定是比坠落要慢的。
private void FindNearlyNodeToOpenList(AStarNode u, AStarNode end) {
for(int i=0; i < u.linkTarget.Count; ++i) {
int nextX = u.linkTarget[i].x;
int nextY = u.linkTarget[i].y;
AStarNode v = map[nextX, nextY];
if(closeList.Contains(v) || openList.Contains(v))
continue;
//计算f值 f=g+h
v.father = new NodeId(u.x, u.y, i);
v.h = cellX * Mathf.Abs((float)(nextY - u.y)) / moveSpeed;
v.h += (float)Mathf.Sqrt(2f * cellY * Mathf.Abs((float)(u.x - nextX)) / 9.8f);
v.g = u.g;
int typeNum = u.linkTarget[i].typeNum;
if(typeNum == -1) {
v.g += (cellX * Mathf.Abs((float)(nextY - u.y))) / moveSpeed;
}
else if(typeNum == -2) {
v.g += (float)Mathf.Sqrt(2f * cellY * (float)(u.x - nextX) / 9.8f);
}
else {
v.g += (cellX * Mathf.Abs((float)(nextY - u.y))) / u.jumps[typeNum].moveSpeed;
}
v.f = v.h + v.g;
openList.Add(v);
}
}
另外openList显然是可以加一个堆优化的,但是我太懒了 这里就先不加了。
寻路测试示例:
按照路线移动
寻路做出来之后,下面的问题就是如何让图片沿着路线移动了。这一部分在处理精灵的运动的脚本里进行。
在这里提一嘴,一个良好的工程习惯是,将控制移动的脚本挂在一个空物体上,然后把带图片的精灵作为它的子物体,子物体只处理图片和动画,空物体来处理逻辑操作。
移动自然可以用rigidbody,但它有点太过灵活,还带反弹什么的=_=,所以我就自己写了一个简约的移动函数。通过Physics2D.OverlapCircle
检测物体是否在地面上,如果在空中,那么纵向的速度有一个大小为g
的加速度……
在实际游戏中,物体不可能每时每刻都位于格点的中心点,所以我们需要通过除以格子的尺寸向下取整的方式,计算物体位于哪个格点。
移动方式分三种:
- 平移:向着目标方向,将横坐标置为预设的平移速度即可。注意接近目标点时不要把横坐标速度设为0,否则在游戏中容易出现移动时一卡一卡的效果。
- 坠落:向着开始坠落的位置平移,在到达坠落格点后,将横坐标置为0。由于我在update里处理了不在地面上的情况,所以物体会自然坠落。
- 跳跃:用一个参数
jumped
记录这一步移动时到底跳没跳。没跳的时候,给一个初速度,然后按照预设改变纵向速度即可。由于在实际移动中可能起跳不是从格点中心位置开始的,所以落地点会存在一些偏差。判断已经跳完了且落地后,可以移动调整偏差。
用currentWayPoint
指针记录当前处于找到的Path上的哪一个点。当物体当前的位置位于路径的下一个格点上时,移动这个指针指向下一个点。
由于地图比较简单我也不知道找最短路有没有BUG,如果发现BUG请告诉我谢谢QAQ
void MoveTo(int nextJ, float nextPosX) {
if(!isGround) return;//是否落地采用Physics2D.OverlapCircle检测即可
if(transform.position.x < nextPosX) {
velocity.x = moveSpeed;
}
else {
velocity.x = -moveSpeed;
}
}
void Chase() {
if(path == null) return;
if(currentWaypoint >= path.Count - 1) {
velocity.x = 0f;
return;
}
int i = path[currentWaypoint].x;
int j = path[currentWaypoint].y;
int typeNum = astar.GetTypeNum(i, j, path[currentWaypoint].typeNum);
int nextI = path[currentWaypoint + 1].x;
int nextJ = path[currentWaypoint + 1].y;
float nextPosX = astar.beginX + (float)nextJ * astar.cellX + 0.5f;
if(typeNum == -1) {//平移
MoveTo(nextJ, nextPosX);
}
else if(typeNum == -2) {//坠落
if(Mathf.Abs(nextPosX - transform.position.x) > 0.05f)
MoveTo(nextJ, nextPosX);
else velocity.x = 0f;
}
else {//跳跃
if(!jumped) {//还没跳过就跳
animator.SetTrigger("Jump");
JumpParameter jp = astar.GetJumpParameter(i, j, typeNum);
velocity = new Vector2(jp.moveSpeed, jp.jumpSpeed);
jumped = true;
}
else if(isGround) {//跳过落地了之后可以调整一下偏差
if(velocity.x > 0 && transform.position.x > nextPosX)
velocity.x 为啥 jumpForce 在检查器中自动更改?