在上一篇文章,介绍了网格地图的实现方式,基于该文章,咱们来实现一个A星寻路的算法,最终实现的效果为:
git
项目源码已上传Github:AStarNavigategithub
在阅读本篇文章,若是你对于里面提到的一些关于网格地图的建立方式的一些地图不了解的话,能够先阅读了解一下下面的这篇文章:算法
文章连接:编程
在介绍A星寻路算法前,先介绍另一种算法:Dijkstra
寻路算法,简单的来讲是一种A星寻路的基础版。Dijkstra
做为一种无启发的寻路算法,经过围绕起始点向四周扩展遍历,一直到找到目标点结束,简单来讲就是暴力破解,由近到远遍历全部可能,从而找到目标点缓存
很明显,这种寻路方式是很的消耗性能的,很是的不高效,有没有更好的解决方式呢dom
从实际生活中出发,若是你要到达某地,殊不知道具体的路该怎么办呢,是否是先大概肯定方向,边靠近目标点边问路呢性能
A星寻路算法也是基于这样的思路,经过必定的逻辑找到能够靠近物体的方向,而后一步步的走进目标点,直到到达目的地。学习
整个理解过程是一个线性结构,只须要一步步完整的走下去,基本就能够对于A星有一个大概的了解。this
肯定直角斜角权重:.net
本质上来说,A星寻路是基于一种网格的地图来实现的寻路的方式,在网格中,一个点能够到达的位置为周围的八个方向。而因为水平与垂直和倾斜的方向距离不同,因此咱们在寻路时须要设置不一样的长度:
经过图片能够看出,直线距离与斜线距离是分别等腰直角三角形直角边与斜边。根据勾股定理咱们能够得知二者的比例关系约为1.41:1
,为了方便计算,咱们就将斜边权重为14
,而直角边权重为10
,这样的话,要获得最短的路径,能够按照下面的思路去考虑:
遍历移动格子可能性:
接下来须要考虑第二个问题,在对起始点周围的可移动格子遍历完成后,如何找到最短路径上的那个格子呢,即下一步该走哪个格子,这里就是整个A星寻路算法的核心:
如图,当咱们第一步对起始点A周围全部的格子遍历后,从A出发有八个能够移动的方向能够到达下一个格子。若是你做为一我的类,固然一眼能够看出下一步向绿色箭头方向移动产生的路径是最短的。
咱们人类能够根据经验很快的判断出方向,可是机器不能,计算机须要严谨的程序逻辑来实现这样的效果,须要咱们赋予他基本的执行程序。经过重复的执行这样的逻辑,获得最终的效果。所以,接下来,须要思考如何让计算机在一系列点位中找到方向最正确的那个点位
计算某一格子指望长度:
到目前,咱们的目的就是使计算机能够找到找到全部能够走的格子中产生路径最短的格子。接下来以你的经验来思考,比较长短每每是依据什么。嘿嘿,别想歪,确实是数字的大小。因此咱们须要给每个格子一个数值来做为路径经过该格子的代价。
当程序进行到如今,要解决的问题是如何求得一个数字来表明该格子。实现方式是经过计算一个经过格子路径长度的对比来找到最短的路径。而任一格子记录路径长度标记为All,并能够将其分为两部分:已走路径与预估路径(不理解不要紧,接着往下看):
如图(灵魂画手,顺便加个防伪标志嘿嘿)求从A到B点的路径,当前已经寻路到C点,如何求得通过该点的一个指望路径的长度呢:
G
:G
值的计算是基于递推的思想,根据上一个格子的G再加上上一个格子到这个格子的距离便可而后就能够求出该点的整个指望路径长度All,对G和H进行一个简单的加法:
这样咱们就能够经过下一步全部可能的移动的格子中找到最短的格子
关于预估路径长度H的计算:
- 实现对于H的计算的估计有不少,因为原本就是预估,换句话就是否是必定准确的结果,因此咱们能够经过计算当前节点到目标点的直线距离或者水平加垂直距离来得到
在本文章的后面演示案例中,是基于水平加垂直距离来计算预估路径长度H,即在上面的图中,从C到B的预估路径计算方式为:
Hcb = 水平格子差 * 10 + 垂直格子差 * 10
上述步骤总结升级:
假设咱们走到了C点,而且接下来只能从C点向下一步移动,能够在下面的图中看出接下来格子的全部可能性:
下面咱们来手动计算一下4号
和5号
的预估路径长度来帮助你理解这个过程,开始前咱们要知道一条斜边长14
,直边长度为10
:
则AC的长度为:
Lac=4*14=56
4号:
H = Lac + 1 * 14 = 70 G = 2 * 10 + 2 * 10 = 40 All = H + G = 110
5号:
H = Lac + 1 * 10 = 66 G = 2 * 10 + 3 * 10 = 50 All = H + G = 116
通过对比,5号
格子的指望路径长度长于4号
,在计算机运行程序时,会对1到7号都进行这样的计算,而后求得其中的一个最小值并做为下一步的移动目标
注意:
- 如过有两个或者多个相同的最小值,会根据程序的写法选择任意一个,这不影响整个程序的运行思路
进一步升级
咱们发现,上述步骤是有一些问题,由于场景中没有障碍物,因此物体会一直走直线。可是在实际状况中,倘若寻路走进了死胡同,最后的C点周围没有能够移动的点位怎么办呢。
事实上在前面为了便于理解,咱们在A星寻路上将问题简化了,一直以最终点做为下一次寻路的起始点,这种方式是没有办法保证最短的路径的,而在实际的A星寻路中,在每一步中,都会记录新的能够移动的路径加入到列表中,咱们命名这个列表为开放列表,找到最短的一个节点后,将该点移除,并加入另一个节点,命名为关闭列表,具体的能够这么说
图中信息注解:
经过反复的观看这张动图,相信你应该对于A星寻路有一个完整的理解,接下来,就须要经过编程来实现该寻路算法
一、制做格子预制体模板
若是你以前看过Unity 制做一个网格地图生成组件这篇文章,你应该很清楚接下来要作什么,若是你不了解也没有关系,我这里再演示一遍:
建立一个Cube
,并调整其缩放,挂载一个脚本Grid
,而后编辑该脚本:
因为是做为寻路的基本格子,所以须要其记录一些信息,咱们定义一些变量:
//格子的坐标位置 public int posX; public int posY; //格子是否为障碍物 public bool isHinder; public Action OnClick; //计算预估路径长度三个值 public int G = 0; public int H = 0; public int All = 0; //记录在寻路过程当中该格子的父格子 public Grid parentGrid;
同时在本项目中格子模板须要一个能够改变其颜色的方法用来标识当前模板所处于的状态(障碍、起始点、终点、路径等等),以及一个注册点击事件的委托方法,因此最后完整的代码为:
using System.Collections; using System.Collections.Generic; using UnityEngine; using System; using UnityEngine.UI; public class Grid : MonoBehaviour { public int posX; public int posY; public bool isHinder; public Action OnClick; //计算预估路径长度三个值 public int G = 0; public int H = 0; public int All = 0; //记录在寻路过程当中该格子的父格子 public Grid parentGrid; public void ChangeColor(Color color) { gameObject.GetComponent<MeshRenderer>().material.color = color; } //委托绑定模板点击事件 private void OnMouseDown() { OnClick?.Invoke(); } }
完成代码的编写后,就能够将其拖入咱们的资源管理窗口Project面板作成一个预制体,或者直接隐藏也能够
注意:
- 若是你不理解我在干什么或者不懂代码的内容,必定要去查看这篇文章:Unity 制做一个网格地图生成组件
二、地图建立
为了提高代码的通用性,在这篇文章中,对于网格地图建立的脚本作出了一些修改,主要在于替换掉脚本中的Grid
变量的定义,转换为GameObject
,因为以前对该脚本有了详细的介绍,因此只贴出了代码:
using System.Collections; using System.Collections.Generic; using UnityEngine; using System; public class GridMeshCreate : MonoBehaviour { [Serializable] public class MeshRange { public int horizontal; public int vertical; } [Header("网格地图范围")] public MeshRange meshRange; [Header("网格地图起始点")] private Vector3 startPos; [Header("建立地图网格父节点")] public Transform parentTran; [Header("网格地图模板预制体")] public GameObject gridPre; [Header("网格地图模板大小")] public Vector2 scale; private GameObject[,] m_grids; public GameObject[,] grids { get { return m_grids; } } //注册模板事件 public Action<GameObject, int, int> gridEvent; /// <summary> /// 基于挂载组件的初始数据建立网格 /// </summary> public void CreateMesh() { if (meshRange.horizontal == 0 || meshRange.vertical == 0) { return; } ClearMesh(); m_grids = new GameObject[meshRange.horizontal, meshRange.vertical]; for (int i = 0; i < meshRange.horizontal; i++) { for (int j = 0; j < meshRange.vertical; j++) { CreateGrid(i, j); } } } /// <summary> /// 重载,基于传入宽高数据来建立网格 /// </summary> /// <param name="height"></param> /// <param name="widght"></param> public void CreateMesh(int height, int widght) { if (widght == 0 || height == 0) { return; } ClearMesh(); m_grids = new GameObject[widght, height]; for (int i = 0; i < widght; i++) { for (int j = 0; j < height; j++) { CreateGrid(i, j); } } } /// <summary> /// 根据位置建立一个基本的Grid物体 /// </summary> /// <param name="row">x轴坐标</param> /// <param name="column">y轴坐标</param> public void CreateGrid(int row, int column) { GameObject go = GameObject.Instantiate(gridPre, parentTran); //T grid = go.GetComponent<T>(); float posX = startPos.x + scale.x * row; float posZ = startPos.z + scale.y * column; go.transform.position = new Vector3(posX, startPos.y, posZ); go.SetActive(true); m_grids[row, column] = go; gridEvent?.Invoke(go, row, column); } /// <summary> /// 删除网格地图,并清除缓存数据 /// </summary> public void ClearMesh() { if (m_grids == null || m_grids.Length == 0) { return; } foreach (GameObject go in m_grids) { if (go != null) { Destroy(go); } } Array.Clear(m_grids, 0, m_grids.Length); } }
三、实现寻路的过程:
建立一个脚本命名为AStarLookRode
做为寻路的脚本
变量定义:
在正式的逻辑代码开始前,须要定义一些基本的变量:
完成变量的定义后,须要在寻路程序开始,对一些变量进行赋值,同时初始化列表,因此咱们定义一个初始化的方法:
public GridMeshCreate meshMap; public Grid startGrid; public Grid endGrid; public List<Grid> openGrids; public List<Grid> closeGrids; public Stack<Grid> rodes; public void Init(GridMeshCreate meshMap, Grid startGrid, Grid endGrid) { this.meshMap = meshMap; this.startGrid = startGrid; this.endGrid = endGrid; openGrids = new List<Grid>(); closeGrids = new List<Grid>(); rodes = new Stack<Grid>(); }
添加路径点周围格子至开放列表:
接下来进行一个功能的代码逻辑设计,如何将一个点周围的格子加入到开放列表。能够观察场景中的格子,有下面的两种状况:
这就须要咱们从中找到能够取值的范围,因为格子的位置信息是一个二维坐标,X
和Y
,单纯的从X
轴来考虑,X-1
会是格子左边的格子的坐标,可是若是X-1<0
则说明其左边没有格子,基于这样的计算方式,来求得当前格子item
周围格子的坐标范围,并剔除一些不须要添加的格子,具体的选择步骤为:
grid
,若是存在于封闭列表closeGrids
内,不处理openGrids
中,计算该点位到目前寻路位置点的指望路径长度,若是长度更短的话,将当前格子item
的父物体替换为该格子的grid
grid
既不在开放列表openGrids
,也再也不闭合列表closeGrids
内,若判断不为障碍物,则将其加入开放列表openGrids
,并设置其父物体为当前寻路位置item
简单的从图中理解:
假定咱们如今走到了A
点(A
表明当前路径点Item
),那么添加其周围的格子(用grid
表明)范围限定在红色框,为了便于区分不一样的状况,我作了一些简单的标识:
closeList
内,不处理openList
里面C
:最须要理解的一个格子,首先要明白,该格子已经被其上面的绿色格子遍历过,简单的来讲是已经在开放列表内,这个时候咱们就要判断A
点若是通过C
点过来,路径会不会更短,若是会,则修改该A
点的父元素为C
点,不然不处理public void TraverseItem(int i, int j) { int xMin = Mathf.Max(i - 1, 0); int xMax = Mathf.Min(i + 1, meshMap.meshRange.horizontal - 1); int yMin = Mathf.Max(j - 1, 0); int yMax = Mathf.Min(j + 1, meshMap.meshRange.vertical - 1); Grid item = meshMap.grids[i, j].GetComponent<Grid>(); for (int x = xMin; x <= xMax; x++) { for (int y = yMin; y <= yMax; y++) { Grid grid = meshMap.grids[x, y].GetComponent<Grid>(); if ((y == j && i == x) || closeGrids.Contains(grid)) { continue; } if (openGrids.Contains(grid)) { if(item.All > GetLength(grid, item)) { item.parentGrid = grid; SetNoteData(item); } continue; } if (!grid.isHinder) { openGrids.Add(grid); grid.parentGrid= item; } } } }
求任一格子的指望路径长度:
接下来就须要计算出一个格子的指望路径的长度,要基于的父元素的G
来计算出该格子的G
,同时预估出来该格子到达目标的距离H,计算方式在原理里面已经介绍过,这里直接贴出代码的执行方式:
public int SetNoteData(Grid grid) { Grid itemParent = rodes.Count == 0 ? startGrid : grid.parentGrid; int numG = Mathf.Abs(itemParent.posX - grid.posX) + Mathf.Abs(itemParent.posY - grid.posY); int n = numG == 1 ? 10 : 14; grid.G = itemParent.G + n; int numH = Mathf.Abs(endGrid.posX - grid.posX) + Mathf.Abs(endGrid.posY - grid.posY); grid.H = numH * 10; grid.All = grid.H + grid.G; return grid.All; }
在前面的代码中,有一个开放列表中已经存在,对比指望长度的更改父格子的功能功能。用到了求根据一个格子求下一个格子指望路径长度的功能。虽然与上面的代码功能相似,可是不能直接使用,提高通用性修改起来又麻烦,因此直接再写一个:
public int GetLength(Grid bejinGrid,Grid grid) { int numG = Mathf.Abs(bejinGrid.posX - grid.posX) + Mathf.Abs(bejinGrid.posY - grid.posY); int n = numG == 1 ? 10 : 14; int G = bejinGrid.G + n; int numH = Mathf.Abs(endGrid.posX - grid.posX) + Mathf.Abs(endGrid.posY - grid.posY); int H = numH * 10; int All = grid.H + grid.G; return All; }
开放列表中寻找指望路径最短的格子:
在完成对于一个格子的指望路径长度的计算,咱们就须要从开放列表中找出指望路径长度最短的路径加入到路径栈中
可是在这一步有这样的一个问题,在原理介绍中也有说到,寻路过程当中遇到障碍会进行回溯到以前的某一个路径点,若是在在栈中执行这样的操做呢
这里就要用到格子模板Grid
中存储的父格子的信息,经过对比栈中的数据,查找到父格子的位置,清除后面的数据,并将该格子插入,具体代码为:
/// <summary> /// 在开放列表选中路径最短的点加入的路径栈,同时将路径点加入到闭合列表中 /// </summary> public void Traverse() { if (openGrids.Count == 0) { return; } Grid minLenthGrid = openGrids[0]; int minLength = SetNoteData(minLenthGrid); for (int i = 0; i < openGrids.Count; i++) { if (minLength > SetNoteData(openGrids[i])) { minLenthGrid = openGrids[i]; minLength = SetNoteData(openGrids[i]); } } minLenthGrid.ChangeColor(Color.green); Debug.Log("我在寻找人生的方向" + minLenthGrid.posX + "::::" + minLenthGrid.posY); closeGrids.Add(minLenthGrid); openGrids.Remove(minLenthGrid); rodes.Push(minLenthGrid); }
获取最终路径:
在完成寻路的步骤后,须要根据路径栈和格子的父物体来找到最短的路径,这里比较功能逻辑比较清晰,直接贴代码:
void GetRode() { List<Grid> grids = new List<Grid>(); rodes.Peek().ChangeColor(Color.black); grids.Insert(0, rodes.Pop()); while (rodes.Count != 0) { if (grids[0].parentGrid != rodes.Peek()) { rodes.Pop(); } else { rodes.Peek().ChangeColor(Color.black); grids.Insert(0, rodes.Pop()); } } }
封装方法,对外暴露:
在解决三个关键功能的代码后,就须要经过一个方法来完成整个寻路的过程,在方法的最后须要经过对终点坐标与栈顶物体的坐标进行对比,若是相同,则跳出循环,执行路径查找完成后的操做
同时为了在本案例中为了使得整个寻路过程的步骤可视化,使用一个协程来完成寻路过程的方法调用,这样,在每一次完成一格的寻路后,能够经过协程来延时执行下一次循环:
public IEnumerator OnStart() { //Item itemRoot = Map.bolls[0].item; rodes.Push(startGrid); closeGrids.Add(startGrid); TraverseItem(startGrid.posX, startGrid.posY); yield return new WaitForSeconds(1); Traverse(); //为了不没法完成寻路而跳不出循环的状况,使用For来规定寻路的最大步数 for (int i = 0; i < 6000; i++) { if (rodes.Peek().posX == endGrid.posX && rodes.Peek().posY == endGrid.posY) { GetRode(); break; } TraverseItem(rodes.Peek().posX, rodes.Peek().posY); yield return new WaitForSeconds(0.2f); Traverse(); } }
接下来须要建立一个脚本明命名为MainRun 来执行整个项目,主要部分为建立场景的网格地图,在前面反复提到的文章里面已经有这一部分的介绍。接下来就须要对A星的调用:
using System.Collections; using System.Collections.Generic; using UnityEngine; public class MainRun : MonoBehaviour { //获取网格建立脚本 public GridMeshCreate gridMeshCreate; //控制网格元素grid是障碍的几率 [Range(0,1)] public float probability; bool isCreateMap=false; int clickNum=0; Grid startGrid; Grid endGrid; private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { Run(); isCreateMap = false; clickNum = 0; } if (Input.GetKeyDown(KeyCode.Q)) { AStarLookRode aStarLookRode = new AStarLookRode(); aStarLookRode.Init(gridMeshCreate,startGrid,endGrid); StartCoroutine(aStarLookRode.OnStart()); } } private void Run() { gridMeshCreate.gridEvent = GridEvent; gridMeshCreate.CreateMesh(); } /// <summary> /// 建立grid时执行的方法,经过委托传入 /// </summary> /// <param name="grid"></param> private void GridEvent(GameObject go,int row,int column) { //几率随机决定该元素是否为障碍 Grid grid = go.GetComponent<Grid>(); float f = Random.Range(0, 1.0f); Color color = f <= probability ? Color.red : Color.white; grid.ChangeColor(color); grid.isHinder = f <= probability; grid.posX = row; grid.posY = column; //模板元素点击事件 grid.OnClick = () => { if (grid.isHinder) return; clickNum++; switch (clickNum) { case 1: startGrid = grid; grid.ChangeColor(Color.yellow); break; case 2: endGrid = grid; grid.ChangeColor(Color.yellow); isCreateMap = true; break; default: break; } }; } }
在该脚本中,主要是用来执行网格地图建立的方法的,同时写入A星脚本的执行接口。
场景执行:
建立一个空物体,并挂载网格地图建立脚本GridMeshCreate
与运行脚本MainRun
,而后对这两个脚本进行赋值:
在两个脚本中,咱们能够控制一些变量来改变网建立网格地图大小与障碍物的占比:
MainRun
中Probability
:用来控制地图中障碍物的数量占比GridMeshCreate
中Mesh Range
:用来控制网格地图的大小范围在完成上面的脚本挂载与设置后,就能够运行游戏,进入游戏场景后,点击空格便可建立地图,
在建立地图后,可使用鼠标点击地图中的白色格子,第一次点击表示选择了路径的起始点,而第二次点击表示选择了目标点格子
注意:
- 这一块Bug挺多的,我也没有修改,因此尽可能按着提示来,不要非要点击障碍物,或者非要在场景中点击三次
在完成对于两个关键节点的选择后,就能够点击Q键
开始执行寻路过程,而后就能够直接观察整个场景中的运行流程:
算法复杂度问题:
第一张图片:障碍物的比例比较低时,寻找的路径接近于一条直线,同时没有多余的寻路节点产生:
当地图复杂度上升后,A星寻路产生巨大的代价才能获取最后的路径,而这些代价产生的缘由是因为为了获取最短的路径而进行大量的回溯,而回溯又进一步形成了遍历列表长度的增长,进一步的消耗了计算资源。
因此当地图复杂度到达必定阈值并再次上升后,寻路的代价会急速的上升,也能够简单的理解为指数的形式,而当这一数值超过了0.5
,地图基本就处于不可用的状态,会有大量的死胡同,很大几率形成无路可循。
特殊状况的寻路效果:
话很少说,先看图:
经过上图能够看出,虽然场景中的网格地图很简单,可是当两个寻路点之间存在比较大的横截面时,也一样会付出巨大的寻路代价
扩展:
- 看到这张图,你知道Unity官方的NavMesh是如何实现寻路的吗?
当咱们使用NavMesh
来执行寻路操做时,会事先对场景进行烘培,若是你曾经观察过这张烘培地图,就会发现其是由一张张三角面来构成的,而当咱们进入游戏,执行寻路操做时,NavMesh
就会根据这些三角面的顶点来执行可移动的路径计算。
如图,其实NavMesh
的优点在与烘培阶段对于地图障碍的处理,经过一些顶点来大大简化了寻路时的计算。
若是你先学习NavMesh 的使用方式:
- 能够经过该文章:unity中Navigation实现自动寻路功能
总的来讲,A星是目前应用最广的寻路方式,其特色简单明了,整个过程以最短路径为设计准则,逐渐的接近目标点。
可是要注意,A星虽然一直以最短为驱动,可是最终获得的路径不必定最短(至少本篇文章的案例是这样)。至于缘由,你若是理解了代码的实现过程应该也能明白,若是你不理解,知道缘由也没意义,嘿嘿!