这篇文章翻译自Unity 4.x Game AI Programming这本书第七章
在本章中,我们将在Unity3D环境中使用C#实现A*算法.尽管有很多其他算法,像Dijkstra算法,但A*算法以其简单性和有效性而广泛的应用于游戏和交互式应用中.我们之前在第一章AI介绍中短暂的涉及到了该算法.不过现在我们从实现的角度来再次复习该算法.
A*算法复习
在我们进入下一部分实现A*之前,我们再次复习一下.首先,我们将用可遍历的数据结构来表示地图.尽管可能有很多结果,在这个例子中我们将使用2D格子数组.我们稍后将实现GridManager类来处理这个地图信息.<Todo>我们的类GridManager将记录一系列的Node对象,这些Node对象才是2D格子的主题.所以我们需要实现Node类来处理一些东西,比如节点类型,他是是一个可通行的节点还是障碍物,穿过节点的代价和到达目标节点的代价等等.
我们将用两个变量来存储已经处理过的节点和我们要处理的节点.我们分别称他们为关闭列表和开放列表.我们将在PriorityQueue类里面实现该列表类型.我们现在看看它:
- 首先,从开始节点开始,将开始节点放入开放列表中.
- 只要开放列表中有节点,我们将进行一下过程.
- 从开放列表中选择第一个节点并将其作为当前节点(我们将在代码结束时提到它,这假定我们已经对开放列表排好序且第一个节点有最小代价值).
- 获得这个当前节点的邻近节点,它们不是障碍物,像一堵墙或者不能穿越的峡谷一样.
- 对于每一个邻近节点,检查该邻近节点是否已在关闭列表中.如果不在,我们将为这个邻近节点计算所有代价值(F),计算时使用下面公式:F = G + H,在前面的式子中,G是从上一个节点到这个节点的代价总和,H是从当前节点到目的节点的代价总和.
- 将代价数据存储在邻近节点中,并且将当前节点保存为该邻近节点的父节点.之后我们将使用这个父节点数据来追踪实际路径.
- 将邻近节点存储在开放列表中.根据到他目标节点的代价总和,以升序排列开放列表.
- 如果没有邻近节点需要处理,将当前节点放入关闭列表并将其从开放列表中移除.
- 返回第二步
这就是我们将在Unity3D中使用C#实现的算法概览.所以,搞起吧.
实现
Node
- using UnityEngine;
- using System.Collections;
- using System;
- public class Node : IComparable {
- public float nodeTotalCost;
- public float estimatedCost;
- public bool bObstacle;
- public Node parent;
- public Vector3 position;
- public Node() {
- this.estimatedCost = 0.0f;
- this.nodeTotalCost = 1.0f;
- this.bObstacle = false;
- this.parent = null;
- }
- public Node(Vector3 pos) {
- this.estimatedCost = 0.0f;
- this.nodeTotalCost = 1.0f;
- this.bObstacle = false;
- this.parent = null;
- this.position = pos;
- }
- public void MarkAsObstacle() {
- this.bObstacle = true;
- }
- public int CompareTo(object obj)
- {
- Node node = (Node) obj;
- //Negative value means object comes before this in the sort order.
- if (this.estimatedCost < node.estimatedCost)
- return -1;
- //Positive value means object comes after this in the sort order.
- if (this.estimatedCost > node.estimatedCost)
- return 1;
- return 0;
- }
PriorityQueue
- using UnityEngine;
- using System.Collections;
- public class PriorityQueue {
- private ArrayList nodes = new ArrayList();
- public int Length {
- get { return this.nodes.Count; }
- }
- public bool Contains(object node) {
- return this.nodes.Contains(node);
- }
- public Node First() {
- if (this.nodes.Count > 0) {
- return (Node)this.nodes[0];
- }
- return null;
- }
- public void Push(Node node) {
- this.nodes.Add(node);
- this.nodes.Sort();
- }
- public void Remove(Node node) {
- this.nodes.Remove(node);
- //Ensure the list is sorted
- this.nodes.Sort();
- }
- }
GridManager
- using UnityEngine;
- using System.Collections;
- public class GridManager : MonoBehaviour {
- private static GridManager s_Instance = null;
- public static GridManager instance {
- get {
- if (s_Instance == null) {
- s_Instance = FindObjectOfType(typeof(GridManager))
- as GridManager;
- if (s_Instance == null)
- Debug.Log("Could not locate a GridManager " +
- "object. \n You have to have exactly " +
- "one GridManager in the scene.");
- }
- return s_Instance;
- }
- }
- <span style="white-space:pre"> </span>public int numOfRows;
- public int numOfColumns;
- public float gridCellSize;
- public bool showGrid = true;
- public bool showObstacleBlocks = true;
- private Vector3 origin = new Vector3();
- private GameObject[] obstacleList;
- public Node[,] nodes { get; set; }
- public Vector3 Origin {
- get { return origin; }
- }
- void Awake() {
- obstacleList = GameObject.FindGameObjectsWithTag("Obstacle");
- CalculateObstacles();
- }
- // Find all the obstacles on the map
- void CalculateObstacles() {
- nodes = new Node[numOfColumns, numOfRows];
- int index = 0;
- for (int i = 0; i < numOfColumns; i++) {
- for (int j = 0; j < numOfRows; j++) {
- Vector3 cellPos = GetGridCellCenter(index);
- Node node = new Node(cellPos);
- nodes[i, j] = node;
- index++;
- }
- }
- if (obstacleList != null && obstacleList.Length > 0) {
- //For each obstacle found on the map, record it in our list
- foreach (GameObject data in obstacleList) {
- int indexCell = GetGridIndex(data.transform.position);
- int col = GetColumn(indexCell);
- int row = GetRow(indexCell);
- nodes[row, col].MarkAsObstacle();
- }
- }
- }
- public Vector3 GetGridCellCenter(int index) {
- Vector3 cellPosition = GetGridCellPosition(index);
- cellPosition.x += (gridCellSize / 2.0f);
- cellPosition.z += (gridCellSize / 2.0f);
- return cellPosition;
- }
- public Vector3 GetGridCellPosition(int index) {
- int row = GetRow(index);
- int col = GetColumn(index);
- float xPosInGrid = col * gridCellSize;
- float zPosInGrid = row * gridCellSize;
- return Origin + new Vector3(xPosInGrid, 0.0f, zPosInGrid);
- }
- public int GetGridIndex(Vector3 pos) {
- if (!IsInBounds(pos)) {
- return -1;
- }
- pos -= Origin;
- int col = (int)(pos.x / gridCellSize);
- int row = (int)(pos.z / gridCellSize);
- return (row * numOfColumns + col);
- }
- public bool IsInBounds(Vector3 pos) {
- float width = numOfColumns * gridCellSize;
- float height = numOfRows* gridCellSize;
- return (pos.x >= Origin.x && pos.x <= Origin.x + width &&
- pos.x <= Origin.z + height && pos.z >= Origin.z);
- }
- public int GetRow(int index) {
- int row = index / numOfColumns;
- return row;
- }
- public int GetColumn(int index) {
- int col = index % numOfColumns;
- return col;
- }
- public void GetNeighbours(Node node, ArrayList neighbors) {
- Vector3 neighborPos = node.position;
- int neighborIndex = GetGridIndex(neighborPos);
- int row = GetRow(neighborIndex);
- int column = GetColumn(neighborIndex);
- //Bottom
- int leftNodeRow = row - 1;
- int leftNodeColumn = column;
- AssignNeighbour(leftNodeRow, leftNodeColumn, neighbors);
- //Top
- leftNodeRow = row + 1;
- leftNodeColumn = column;
- AssignNeighbour(leftNodeRow, leftNodeColumn, neighbors);
- //Right
- leftNodeRow = row;
- leftNodeColumn = column + 1;
- AssignNeighbour(leftNodeRow, leftNodeColumn, neighbors);
- //Left
- leftNodeRow = row;
- leftNodeColumn = column - 1;
- AssignNeighbour(leftNodeRow, leftNodeColumn, neighbors);
- }
- void AssignNeighbour(int row, int column, ArrayList neighbors) {
- if (row != -1 && column != -1 &&
- row < numOfRows && column < numOfColumns) {
- Node nodeToAdd = nodes[row, column];
- if (!nodeToAdd.bObstacle) {
- neighbors.Add(nodeToAdd);
- }
- }
- }
- void OnDrawGizmos() {
- if (showGrid) {
- DebugDrawGrid(transform.position, numOfRows, numOfColumns,
- gridCellSize, Color.blue);
- }
- Gizmos.DrawSphere(transform.position, 0.5f);
- if (showObstacleBlocks) {
- Vector3 cellSize = new Vector3(gridCellSize, 1.0f,
- gridCellSize);
- if (obstacleList != null && obstacleList.Length > 0) {
- foreach (GameObject data in obstacleList) {
- Gizmos.DrawCube(GetGridCellCenter(
- GetGridIndex(data.transform.position)), cellSize);
- }
- }
- }
- }
- public void DebugDrawGrid(Vector3 origin, int numRows, int
- numCols,float cellSize, Color color) {
- float width = (numCols * cellSize);
- float height = (numRows * cellSize);
- // Draw the horizontal grid lines
- for (int i = 0; i < numRows + 1; i++) {
- Vector3 startPos = origin + i * cellSize * new Vector3(0.0f,
- 0.0f, 1.0f);
- Vector3 endPos = startPos + width * new Vector3(1.0f, 0.0f,
- 0.0f);
- Debug.DrawLine(startPos, endPos, color);
- }
- // Draw the vertial grid lines
- for (int i = 0; i < numCols + 1; i++) {
- Vector3 startPos = origin + i * cellSize * new Vector3(1.0f,
- 0.0f, 0.0f);
- Vector3 endPos = startPos + height * new Vector3(0.0f, 0.0f,
- 1.0f);
- Debug.DrawLine(startPos, endPos, color);
- }
- }
- }
AStar
类AStar是将要使用我们目前所实现的类的主类.如果你想复习着的话,你可以返回算法部分.如下面AStar.cs代码所示,我们先声明我们的openList和closedList,它们都是PriorityQueue类型.
- using UnityEngine;
- using System.Collections;
- public class AStar {
- public static PriorityQueue closedList, openList;
- private static float HeuristicEstimateCost(Node curNode,
- Node goalNode) {
- Vector3 vecCost = curNode.position - goalNode.position;
- return vecCost.magnitude;
- }
- public static ArrayList FindPath(Node start, Node goal) {
- openList = new PriorityQueue();
- openList.Push(start);
- start.nodeTotalCost = 0.0f;
- start.estimatedCost = HeuristicEstimateCost(start, goal);
- closedList = new PriorityQueue();
- Node node = null;
- while (openList.Length != 0) {
- node = openList.First();
- //Check if the current node is the goal node
- if (node.position == goal.position) {
- return CalculatePath(node);
- }
- //Create an ArrayList to store the neighboring nodes
- ArrayList neighbours = new ArrayList();
- GridManager.instance.GetNeighbours(node, neighbours);
- for (int i = 0; i < neighbours.Count; i++) {
- Node neighbourNode = (Node)neighbours[i];
- if (!closedList.Contains(neighbourNode)) {
- float cost = HeuristicEstimateCost(node,
- neighbourNode);
- float totalCost = node.nodeTotalCost + cost;
- float neighbourNodeEstCost = HeuristicEstimateCost(
- neighbourNode, goal);
- neighbourNode.nodeTotalCost = totalCost;
- neighbourNode.parent = node;
- neighbourNode.estimatedCost = totalCost +
- neighbourNodeEstCost;
- if (!openList.Contains(neighbourNode)) {
- openList.Push(neighbourNode);
- }
- }
- }
- //Push the current node to the closed list
- closedList.Push(node);
- //and remove it from openList
- openList.Remove(node);
- }
- if (node.position != goal.position) {
- Debug.LogError("Goal Not Found");
- return null;
- }
- return CalculatePath(node);
- }
- 获得openList的第一个节点.记住每当新节点加入时openList都需要再次排序.所以第一个节点总是有到目的节点最低估计代价值.
- 检查当前节点是否是目的节点,如果是推出while循环创建path数组.
- 创建数组列表保存当前正被处理的节点的临近节点.使用GetNeighbours方法来从格子中检索邻接节点.
- 对于每一个在邻接节点数组中的节点,我们检查它是否已在closedList中.如果不在,计算代价值并使用新的代价值更新节点的属性值,更新节点的父节点并将其放入openList中.
- 将当前节点压入closedList中并将其从openList中移除.返回第一步.
- private static ArrayList CalculatePath(Node node) {
- ArrayList list = new ArrayList();
- while (node != null) {
- list.Add(node);
- node = node.parent;
- }
- list.Reverse();
- return list;
- }
这就是我们的AStar类.我们将在下面的代码里写一个测试脚本来检验所有的这些东西.之后创建一个场景并在其中使用它们.
TestCode Class
代码如TestCode.cs所示,该类使用AStar类找到从开始节点到目的节点的路径.
- using UnityEngine;
- using System.Collections;
- public class TestCode : MonoBehaviour {
- private Transform startPos, endPos;
- public Node startNode { get; set; }
- public Node goalNode { get; set; }
- public ArrayList pathArray;
- GameObject objStartCube, objEndCube;
- private float elapsedTime = 0.0f;
- //Interval time between pathfinding
- public float intervalTime = 1.0f;
- void Start () {
- objStartCube = GameObject.FindGameObjectWithTag("Start");
- objEndCube = GameObject.FindGameObjectWithTag("End");
- pathArray = new ArrayList();
- FindPath();
- }
- void Update () {
- elapsedTime += Time.deltaTime;
- if (elapsedTime >= intervalTime) {
- elapsedTime = 0.0f;
- FindPath();
- }
- }
- void FindPath() {
- startPos = objStartCube.transform;
- endPos = objEndCube.transform;
- startNode = new Node(GridManager.instance.GetGridCellCenter(
- GridManager.instance.GetGridIndex(startPos.position)));
- goalNode = new Node(GridManager.instance.GetGridCellCenter(
- GridManager.instance.GetGridIndex(endPos.position)));
- pathArray = AStar.FindPath(startNode, goalNode);
- }
- void OnDrawGizmos() {
- if (pathArray == null)
- return;
- if (pathArray.Count > 0) {
- int index = 1;
- foreach (Node node in pathArray) {
- if (index < pathArray.Count) {
- Node nextNode = (Node)pathArray[index];
- Debug.DrawLine(node.position, nextNode.position,
- Color.green);
- index++;
- }
- }
- }
- }
Scene setup
我们将要创建一个类似于下面截图所展示的场景:
Sample test scene
我们将有一个平行光,开始以及结束游戏对象,一些障碍物,一个被用作地面的平面实体和两个空的游戏对象,空对象身上放置GridManager和TestAstar脚本.这是我们的场景层级图.
Scene hierarchy
创建一些立方体实体并给他们加上标签Obstacle,当运行我们的寻路算法时我们需要寻找带有该标签的对象.
Obstacle nodes
创建一个立方体实体并加上标签Start
Start node
创建另一个立方体实体并加上标签End
End node
现在创建一个空的游戏对象并将GridManager脚本赋给它.将其名字也设置回GridManager因为在我们的脚本中使用该名称寻找GridManager对象.这里我们可以设置格子的行数和列数和每个格子的大小.
GridManager script
Testing
我们点击Play按钮实打实的看下我们的A*算法.默认情况下,一旦你播放当前场景Unity3D将会切换到Game视图.由于我们的寻路形象化(visualization)代码是为我编辑器视图中的调试绘制而写,你需要切换回Scene视图或者勾选Gizmos来查看找到的路径.
现在在场景中尝试使用编辑器的移动工具移动开始和结束节点.(不是在Game视图中,而是在Scene视图中)
如果从开始节点到目的节点有合法路径,你应该看到路径会对应更新并且是动态实时的更新.如果没有路径,你会在控制窗口中得到一条错误信息.
总结
在本章中,我们学习了如何在Unity3D环境中实现A*寻路算法.我们实现了自己的A*寻路类以及我们自己的格子类,队列类和节点类.我们学习了IComparable接口并重写了CompareTo方法.我们使用调试绘制功能(debug draw functionalities)来呈现我们的网格和路径信息.有了Unity3D的navmesh和navagent功能你可能不必自己实现寻路算法.不管怎么样,他帮助你理解实现背后的基础算法.
在下一章中,我们将查看如何扩展藏在A*背后的思想看看导航网格(navigation meshes).使用导航网格,在崎岖的地形上寻路将变得容易得多.