复活之后发的第一篇博客!
在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调参然后人为地一条条连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));
}
}
边练好了,就可以开始在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
的加速度……
在实际游戏中,物体不可能每时每刻都位于格点的中心点,所以我们需要通过除以格子的尺寸向下取整的方式,计算物体位于哪个格点。
移动方式分三种:
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地图,这个是免费资源。