【Unity】Unity实现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输出一下检测的效果(黄色表示边缘,红色表示非边缘):【Unity】Unity实现2D平台游戏带跳跃的自动寻路功能_第1张图片
好耶ヾ(✿゚▽゚)ノ

平移和坠落链接

有了点之后,就应该有连边。连边分为三种,平移,坠落和跳跃。

首先来说说平移,这个应该是非常好实现的,在扫描地图,检测边缘的时候我们就判断过当前格点的左边是否也是一个落脚点,所以在扫描的同时,我们就可以连好所有的平移边,在上一份代码中其实已经体现出来了。

然后是坠落,简化起见,我们假设坠落只发生在平台边缘,并且坠落的过程中不带横向的速度。那么我们只需要向下搜索坠落后会落到哪个点,然后进行链接即可。

//========================坠落链接=====================
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打印出连好的坠落边看一看吧:
【Unity】Unity实现2D平台游戏带跳跃的自动寻路功能_第2张图片

跳跃链接

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

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

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

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

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

采样示例(这个蓝色可能有点看不清):
【Unity】Unity实现2D平台游戏带跳跃的自动寻路功能_第3张图片
不过,我最后还是使用了全手动跳跃连边方式,就是用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));
	}
}

跳跃连边的效果:
【Unity】Unity实现2D平台游戏带跳跃的自动寻路功能_第4张图片

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显然是可以加一个堆优化的,但是我太懒了 这里就先不加了。

寻路测试示例:
【Unity】Unity实现2D平台游戏带跳跃的自动寻路功能_第5张图片

按照路线移动

寻路做出来之后,下面的问题就是如何让图片沿着路线移动了。这一部分在处理精灵的运动的脚本里进行。

在这里提一嘴,一个良好的工程习惯是,将控制移动的脚本挂在一个空物体上,然后把带图片的精灵作为它的子物体,子物体只处理图片和动画,空物体来处理逻辑操作。

移动自然可以用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 = -moveSpeed;
				else if(velocity.x < 0 && transform.position.x < nextPosX)
					velocity.x = moveSpeed;
			}
		}
		
		Vector2 pos = astar.GetPosition(nextI, nextJ);

		int nowI = (int)Mathf.Floor((transform.position.y - astar.beginY) / astar.cellY);
		int nowJ = (int)Mathf.Floor((transform.position.x - astar.beginX) / astar.cellX);

		//物体当前的位置位于路径的下一个格点上时
		if(nowI == nextI && nowJ == nextJ) {
     
			currentWaypoint ++;
			jumped = false;
		}
	}

重新自动寻路

测试好移动后,寻路肯定不止发生一次,我们来写每隔一段时间就重新自动寻路这一部分。

首先明确一点,我们肯定不希望由于寻路算法太慢(比如某人太懒不写堆优化)导致游戏卡死,所以我们需要通过多线程来实现重新寻路。

每隔一段时间重新寻路一次,只需要用计时器就可以实现了。

接下来的问题是定位起点和终点。我这里是以NPC为起点,主角为终点。若主角在空中,但离脚下平台较近,算作位于脚下平台,若在空中且离脚下平台很远,就暂不重新寻路。

然后还要注意一点,重新寻路必须发生在NPC已经移动到当前路径上的某一个格点时,不能让她还跳在空中的时候,就开始重新寻路了。

注意点就这么多吧,下面是代码:

void Update() {
     
	//计时器
	findPathTimer += Time.deltaTime;
	//其实这里应该把线程函数放进一个类里的,这样比较安全
	//但我太懒了,就直接用全局变量实现传参了
	begin = transform.position;
	end = target.position;
	//多线程寻路
       Thread PathFindThread = new Thread(new ThreadStart(UpdatePath));
       PathFindThread.Start();
}

private Vector2 begin,end;

void UpdatePath() {
     
	if(findPathTimer < findPathTime) return; //计时器
	else findPathTimer = 0;
	if(!astar.InMap(begin) || !astar.InMap(end)) return; //判断是否在图中
	
	//这里的GetMapIdI_Y和GetMapIdJ_X就是前文中的除以格子尺寸向下取整
	int startI = astar.GetMapIdI_Y(begin.y);
	int startJ = astar.GetMapIdJ_X(begin.x);
	int endI = astar.GetMapIdI_Y(end.y);
	int endJ = astar.GetMapIdJ_X(end.x);

	//必须抵达路径上的格点,才会执行重新寻路
	if(path != null && currentWaypoint < path.Count - 1 && 
		(startI != path[currentWaypoint].x || startJ != path[currentWaypoint].y))
			return;

	//如果在离地面较近的空中,则记为正下方的地面,否则等跳跃完成了再寻路
	int i1 = 0, i2 = 0;
	while(startI-i1 >=0 && i1 <= maxAirH && !astar.IsPlat(startI-i1, startJ)) ++i1;
	while(endI-i2 >= 0 && i2 <= maxAirH && !astar.IsPlat(endI-i2, endJ)) ++i2;

	if(startI-i1 < 0 || i1 > maxAirH) return;
	if(endI-i2 < 0 || i2 >maxAirH) return;

	path = astar.FindPath(startI-i1, startJ, endI-i2, endJ);
	currentWaypoint = 0;
}

效果展示

其他探讨

在这个例子中,我们展示的是一个很小的地图。如果需要寻路的地图很大,光是启动时搞一个射线检测,岂不是就要花上很长的时间?

事实上,我们关心的寻路,往往只发生在玩家附近的一小块区域内。也就是说,我们可以每过一段时间,重新扫描一遍玩家附近一块区域的地图,然后将建好的图remake一下,再自动寻路。

这也就是为什么我之前建议跳跃链接也做成全自动的,这样才能方便扫描进行。待之后有时间了我试一下优化吧。

素材使用

https://legnops.itch.io/red-hood-character
https://rvros.itch.io/animated-pixel-hero
还有UnityAssetStore里的SunnyLand地图,这个是免费资源。

你可能感兴趣的