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,然后再将左侧落脚点的RightEdgeSolo更新为PlatformLeftEdge

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打印出连好的坠落边看一看吧:

跳跃链接

接下来是最难的一部分了,如何做跳跃链接。

跳跃,本质上就是画出一条抛物线,如果给定了横向和纵向的初速度,我们可以很轻松的通过初中知识计算出抛物线轨迹。

全自动的跳跃链接思路是这样的:

  1. 根据一定的规则,生成若干跳跃初速度的预设。
  2. 对于每个点,按每一个跳跃预设画出抛物线,并进行采样。
  3. 对每一个采样点,射线检测,或者使用类似的检测方法,检测是否会撞墙。若撞墙,则该跳跃不合法,检测下一个跳跃。
  4. 若在下落过程中碰到地面,则说明找到了一个跳跃的合法终点。
  5. 终点需要判重,还可以取消掉在同水平高度上跳跃的这种无意义行为。

另一种连边思路是,枚举两个要连跳跃边的点,然后扫描两点附近的一块地图,计算跳跃的两个参数。

采样示例(这个蓝色可能有点看不清):

不过,我最后还是使用了全手动跳跃连边方式,就是用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的加速度……

在实际游戏中,物体不可能每时每刻都位于格点的中心点,所以我们需要通过除以格子的尺寸向下取整的方式,计算物体位于哪个格点。

移动方式分三种:

  1. 平移:向着目标方向,将横坐标置为预设的平移速度即可。注意接近目标点时不要把横坐标速度设为0,否则在游戏中容易出现移动时一卡一卡的效果。
  2. 坠落:向着开始坠落的位置平移,在到达坠落格点后,将横坐标置为0。由于我在update里处理了不在地面上的情况,所以物体会自然坠落。
  3. 跳跃:用一个参数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 在检查器中自动更改?

实现Unity2D游戏中跳跃功能和相关问题解决

如何实现横版游戏中角色的跳跃控制

虚幻4:2D游戏中实现二级或多级跳跃

走进Unity

Unity 之 手把手教你实现自己Unity2D游戏寻路逻辑 文末源码