1、目标
我们将使用AStar算法来生成一个场景中两点之间的路径,并使用测试脚本显示这条路径。
要实现的效果如下:

2、AStart算法概念
(1)寻路的定义
寻路就是通过计算机程序找到两点之间的最短路线,在游戏中比如让角色从一个位置移动到另一个位置,需要规划出合理的移动路线 。可以把它理解为解决迷宫问题的实际应用,在迷宫里找从入口到出口的最短路线,在游戏场景中就是找角色起点到终点的最短路径。
(2)AStar(A*)算法

其中G是确定的移动步数,每次计算时增加一步。
H是不确定的,可采用曼哈顿距离或者欧式距离两种方式进行计算。
节点信息表示如下:

举例如下:
【采用欧式距离计算H的情况】

单独查看终点附近的点,即可以定位到通往终点的最小总代价的点:

然后通过该点逆推即可找到最佳路径:

【采用曼哈顿距离计算H的情况】

单独查看终点附近的点,即可以定位到通往终点的最小总代价的点:

然后通过该点逆推即可找到最佳路径:

上述的解释参考:六分钟!带你掌握A星算法的原理!!_哔哩哔哩_bilibili
上述是原理,实际计算时我们按照曼哈顿距离计算,规定横向及纵向为10,斜向为14(每个方格距离为1,一般乘以10计算结果)
另外增加2个概念:
- Open List:代检测节点 容器(装候选节点的容器)
- Close List:已检测节点 容器(防止同一个节点多次出现在路径中)

例子1(没有障碍的情况):

例子2(有障碍的情况):

3、编写代码环节
(1)修改GridPropertiesManager.cs脚本
增加获取网格维度信息的方法,给AStar算法使用。
添加函数如下:
/// <summary>
/// for sceneName this method returns a Vector2Int with the grid dimensions for that scene, or Vector2Int.zero if scene not found
/// </summary>
/// <param name="sceneName"></param>
/// <param name="gridDimensions"></param>
/// <param name="gridOrigin"></param>
/// <returns></returns>
public bool GetGridDimensions(SceneName sceneName, out Vector2Int gridDimensions, out Vector2Int gridOrigin)
{
gridDimensions = Vector2Int.zero;
gridOrigin = Vector2Int.zero;
// loop through scenes
foreach(SO_GridProperties so_GridProperties in so_gridPropertiesArray)
{
if(so_GridProperties.sceneName == sceneName)
{
gridDimensions.x = so_GridProperties.gridWidth;
gridDimensions.y = so_GridProperties.gridHeight;
gridOrigin.x = so_GridProperties.originX;
gridOrigin.y = so_GridProperties.originY;
return true;
}
}
return false;
}
(2)创建Node.cs脚本
在Assets -> Scripts下创建目录命名为AStar,在该目录下创建脚本命名为Node.cs。
用于存储每个寻径节点的详细信息。
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Node : IComparable<Node>
{
public Vector2Int gridPosition;
public int gCost = 0; // distance from starting node
public int hCost = 0; // distance from finishing node
public bool isObstacle = false;
public int movementPenalty;
public Node parentNode;
public Node(Vector2Int gridPosition)
{
this.gridPosition = gridPosition;
parentNode = null;
}
public int FCost
{
get
{
return gCost + hCost;
}
}
public int CompareTo(Node nodeToCompare)
{
int compare = FCost.CompareTo(nodeToCompare.FCost);
if (compare == 0)
{
compare = hCost.CompareTo(nodeToCompare.hCost);
}
return compare;
}
}
(3)创建GridNodes.cs脚本
在Assets -> Scripts -> AStar下创建GridNodes.cs脚本,用于存储grid的二维网格信息。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GridNodes
{
private int width;
private int height;
private Node[,] gridNode;
public GridNodes(int width, int height)
{
this.width = width;
this.height = height;
gridNode = new Node[width, height];
for(int x = 0; x < width; x++)
{
for(int y = 0; y < height; y++)
{
gridNode[x, y] = new Node(new Vector2Int(x, y));
}
}
}
public Node GetGridNode(int xPosition, int yPosition)
{
if(xPosition < width && yPosition < height)
{
return gridNode[xPosition, yPosition];
}
else
{
Debug.Log("Requested grid node is out of range");
return null;
}
}
}
(4)创建NPCMovementStep.cs脚本
在Assets -> Scripts下创建目录命名为NPC,在该目录下创建新的脚本命名为NPCMovementStep.cs。
把NPC走过的每一步记录在这个数据容器里。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class NPCMovementStep
{
public SceneName sceneName;
public int hour;
public int minute;
public int second;
public Vector2Int gridCoordinate;
}
(5)创建AStar.cs脚本
在Assets -> Scripts -> AStar下创建AStar.cs脚本
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AStar : MonoBehaviour
{
[Header("Tiles & Tilemap References")]
[Header("Options")]
[SerializeField] private bool observeMovementPenalties = true;
[Range(0, 20)]
[SerializeField] private int pathMovementPenalty = 0;
[Range(0, 20)]
[SerializeField] private int defaultMovementPenalty = 0;
private GridNodes gridNodes;
private Node startNode;
private Node targetNode;
private int gridWidth;
private int gridHeight;
private int originX;
private int originY;
private List<Node> openNodeList;
private HashSet<Node> closedNodeList;
private bool pathFound = false;
/// <summary>
/// Builds a path for the given sceneName, from the startGridPosition to the endGridPosition, and adds movement steps to the passed in npcMovementStack,
/// Also returns true if path found or false if no path found
/// </summary>
/// <param name="sceneName"></param>
/// <param name="startGridPosition"></param>
/// <param name="endGridPosition"></param>
/// <param name="npcMovementStepStack"></param>
/// <returns></returns>
public bool BuildPath(SceneName sceneName, Vector2Int startGridPosition, Vector2Int endGridPosition, Stack<NPCMovementStep> npcMovementStepStack)
{
if(PopulateGridNodesFromGridPropertiesDictionary(sceneName, startGridPosition, endGridPosition))
{
if (FindShortestPath())
{
UpdatePathOnNPCMovementStepStack(sceneName, npcMovementStepStack);
return true;
}
}
return false;
}
private void UpdatePathOnNPCMovementStepStack(SceneName sceneName, Stack<NPCMovementStep> npcMovementStepStack)
{
Node nextNode = targetNode;
while (nextNode != null)
{
NPCMovementStep nPCMovementStep = new NPCMovementStep();
nPCMovementStep.sceneName = sceneName;
nPCMovementStep.gridCoordinate = new Vector2Int(nextNode.gridPosition.x + originX, nextNode.gridPosition.y + originY);
npcMovementStepStack.Push(nPCMovementStep);
nextNode = nextNode.parentNode;
}
}
/// <summary>
/// return true if a path has been found
/// </summary>
private bool FindShortestPath()
{
// Add start node to open list
openNodeList.Add(startNode);
// Loop through open node list until empty
while(openNodeList.Count > 0)
{
// Sort List
openNodeList.Sort();
// current node = the node in the open list with the lowest fCost
Node currentNode = openNodeList[0];
openNodeList.RemoveAt(0);
// add current node to the closed list
closedNodeList.Add(currentNode);
// if the current node = target node then finish
if(currentNode == targetNode)
{
pathFound = true;
break;
}
// evaluate fcost for each neighbour of the current node
EvaluateCurrentNodeNeighbours(currentNode);
}
if (pathFound)
{
return true;
}
else
{
return false;
}
}
private void EvaluateCurrentNodeNeighbours(Node currentNode)
{
Vector2Int currentNodeGridPosition = currentNode.gridPosition;
Node validNeighbourNode;
// Loop through all direction
for(int i = -1; i <= 1; i++)
{
for(int j = -1; j <=1; j++)
{
if (i == 0 && j == 0)
continue;
validNeighbourNode = GetValidNodeNeighbour(currentNodeGridPosition.x + i, currentNodeGridPosition.y + j);
if(validNeighbourNode != null)
{
// Calculate new gcost for neighbour
int newCostToNeighbour;
if (observeMovementPenalties)
{
newCostToNeighbour = currentNode.gCost + GetDistance(currentNode, validNeighbourNode) + validNeighbourNode.movementPenalty;
}
else
{
newCostToNeighbour = currentNode.gCost + GetDistance(currentNode, validNeighbourNode);
}
bool isValidNeighbourNodeInOpenList = openNodeList.Contains(validNeighbourNode);
if(newCostToNeighbour < validNeighbourNode.gCost || !isValidNeighbourNodeInOpenList)
{
validNeighbourNode.gCost = newCostToNeighbour;
validNeighbourNode.hCost = GetDistance(validNeighbourNode, targetNode);
validNeighbourNode.parentNode = currentNode;
if (!isValidNeighbourNodeInOpenList)
{
openNodeList.Add(validNeighbourNode);
}
}
}
}
}
}
private int GetDistance(Node nodeA, Node nodeB)
{
int dstX = Mathf.Abs(nodeA.gridPosition.x - nodeB.gridPosition.x);
int dstY = Mathf.Abs(nodeA.gridPosition.y - nodeB.gridPosition.y);
if(dstX > dstY)
return 14 * dstY + 10 * (dstX - dstY);
return 14 * dstX + 10 * (dstY - dstX);
}
private Node GetValidNodeNeighbour(int neighbourNodeXPosition, int neighbourNodeYPosition)
{
// If neighbour node position is beyond grid then return null
if(neighbourNodeXPosition >= gridWidth || neighbourNodeXPosition < 0 || neighbourNodeYPosition >= gridHeight || neighbourNodeYPosition < 0)
{
return null;
}
// if neighbour is an obstacle or neighbour is in the closed list then skip
Node neighbourNode = gridNodes.GetGridNode(neighbourNodeXPosition, neighbourNodeYPosition);
if(neighbourNode.isObstacle || closedNodeList.Contains(neighbourNode))
{
return null;
}
else
{
return neighbourNode;
}
}
private bool PopulateGridNodesFromGridPropertiesDictionary(SceneName sceneName, Vector2Int startGridPosition, Vector2Int endGridPosition)
{
// Get grid properties dictionary for the scene
SceneSave sceneSave;
if(GridPropertiesManager.Instance.GameObjectSave.sceneData.TryGetValue(sceneName.ToString(), out sceneSave)) // 获取场景数据
{
// Get Dict grid property details
if(sceneSave.gridPropertyDetailsDictionary != null)
{
// Get grid height and width
if(GridPropertiesManager.Instance.GetGridDimensions(sceneName, out Vector2Int gridDimensions, out Vector2Int gridOrigin))
{
// Create nodes grid based on grid properties dictionary
gridNodes = new GridNodes(gridDimensions.x, gridDimensions.y);
gridWidth = gridDimensions.x;
gridHeight = gridDimensions.y;
originX = gridOrigin.x;
originY = gridOrigin.y;
// Create openNodeList
openNodeList = new List<Node>();
// Create closed Node List
closedNodeList = new HashSet<Node>();
}
else
{
return false;
}
// Populate start node(网格的原点在地图的中间,通过如下转化可以使原点位于左下角)
startNode = gridNodes.GetGridNode(startGridPosition.x - gridOrigin.x, startGridPosition.y - gridOrigin.y);
// Populate target node
targetNode = gridNodes.GetGridNode(endGridPosition.x - gridOrigin.x, endGridPosition.y - gridOrigin.y);
// Populate obstacle and path info for grid
for(int x = 0; x < gridDimensions.x; x++)
{
for(int y = 0; y < gridDimensions.y; y++)
{
GridPropertyDetails gridPropertyDetails = GridPropertiesManager.Instance.GetGridPropertyDetails(x + gridOrigin.x, y + gridOrigin.y,
sceneSave.gridPropertyDetailsDictionary);
if(gridPropertyDetails != null)
{
// If NPC obstacle
if(gridPropertyDetails.isNPCObstacle == true)
{
Node node = gridNodes.GetGridNode(x, y);
node.isObstacle = true;
}
else if(gridPropertyDetails.isPath == true) // 有移动惩罚
{
Node node = gridNodes.GetGridNode(x, y);
node.movementPenalty = pathMovementPenalty;
}
else
{
Node node = gridNodes.GetGridNode(x, y);
node.movementPenalty = defaultMovementPenalty;
}
}
}
}
}
else
{
return false;
}
}
else
{
return false;
}
return true;
}
}
(6)创建AStarTest.cs脚本
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;
using UnityEngine.SceneManagement;
[RequireComponent(typeof(AStar))]
public class AStarTest : MonoBehaviour
{
private AStar aStar;
[SerializeField] private Vector2Int startPosition;
[SerializeField] private Vector2Int finishPosition;
[SerializeField] private Tilemap tileMapToDisplayPathOn = null;
[SerializeField] private TileBase tileToUseToDisplayPath = null;
[SerializeField] private bool displayStartAndFinish = false;
[SerializeField] private bool displayPath = false;
private Stack<NPCMovementStep> npcMovementSteps;
private void Awake()
{
aStar = GetComponent<AStar>();
npcMovementSteps = new Stack<NPCMovementStep>();
}
private void Update()
{
if(startPosition != null && finishPosition != null && tileMapToDisplayPathOn != null && tileToUseToDisplayPath != null)
{
// Display start and finish tiles
if (displayStartAndFinish)
{
// Display start tile
tileMapToDisplayPathOn.SetTile(new Vector3Int(startPosition.x, startPosition.y, 0), tileToUseToDisplayPath);
// Display finish tile
tileMapToDisplayPathOn.SetTile(new Vector3Int(finishPosition.x, finishPosition.y, 0), tileToUseToDisplayPath);
}
else // Clear start and finish
{
// clear start tile
tileMapToDisplayPathOn.SetTile(new Vector3Int(startPosition.x, startPosition.y, 0), null);
// clear finish tile
tileMapToDisplayPathOn.SetTile(new Vector3Int(finishPosition.x, finishPosition.y, 0), null);
}
// Display path
if (displayPath)
{
// Get current scene name
Enum.TryParse<SceneName>(SceneManager.GetActiveScene().name, out SceneName sceneName);
// Build path
aStar.BuildPath(sceneName, startPosition, finishPosition, npcMovementSteps);
// Display path on tilemap
foreach(NPCMovementStep npcMovementStep in npcMovementSteps)
{
tileMapToDisplayPathOn.SetTile(new Vector3Int(npcMovementStep.gridCoordinate.x, npcMovementStep.gridCoordinate.y, 0), tileToUseToDisplayPath);
}
}
else
{
// Clear path
if(npcMovementSteps.Count > 0)
{
// Clear path on tilemap
foreach(NPCMovementStep npcMovementStep in npcMovementSteps)
{
tileMapToDisplayPathOn.SetTile(new Vector3Int(npcMovementStep.gridCoordinate.x, npcMovementStep.gridCoordinate.y, 0), null);
}
// Clear movement steps
npcMovementSteps.Clear();
}
}
}
}
}
4、创建对象
在Hierarchy -> PersistentScene下创建NPCManager对象,
NPCManager对象挂载AStar、AStarTest组件如下所示:

再创建TileMap对象

与Scene1_Farm创建的TilemapGrid的Tilemap类型保持一致,否则网格布局不一致,会导致坐标错乱。

然后给NPCManager对象赋值如下:

5、运行测试1
修改NPCManager的AStarTest组件的Start Position / Finish Position的值,然后勾选Display,可以看到效果如下:

调整Start Position的值:


存在的问题:路径直接穿过了不可行走的区域。
6、创建障碍物
在Scene1_Farm场景中,添加Obstacle后如下:

此时重新运行程序查看路径如下:

此时的路径不会穿过不可行走区域了,但是又有新的问题:上边的路径非常靠近路的边缘,一般情况下我们希望NPC能够行走在马路的中央。所以我们想要做的是让路径更倾向于在路的中间。
7、创建参考路径
在Scene1_Farm场景中,添加Path后如下

此时运行程序,当defaultMovementPenalty=5时路径如下:

当defaultMovementPenalty=20时路径如下:

解释一下:

首先,如果网格的isPath是true,则取pathMovementPenalty为0,其他为defaultMovementPenalty(大于0)。

然后,计算gCost的时候,非isPath的节点gCost更大,此时会倾向于有isPath属性的路径。


2833

被折叠的 条评论
为什么被折叠?



