内容概览
前言
Hi~你好,我是hutian,一名普通二本在读学生,正在学习游戏开发。接下来我会持续更新游戏技术相关的博客,记录我的学习成长历程.希望我的博客能够帮助到同样正在学习游戏开发的你。
吹水
敌人AI一直是很重要的一部分。平台跳跃类游戏中,往往是给敌人ai几个巡逻点,让敌人朝向巡逻点移动,到达巡逻点之后再切换到下一个巡逻点。但如果敌人ai仅仅是按照提前设定好的巡逻点徘徊,玩家很容易对这样的敌人感到厌倦。同时如果地形比较复杂,比如在地牢类游戏动态生成地图,敌人朝着巡逻点移动的过程中很容易被障碍物阻挡,这时设定巡逻点的方法并不好用。
参考《挺进地牢》中的敌人AI。敌人往往能够绕过墙壁,桌子之类的障碍物,找到抵达玩家位置的最优路径,这样的敌人ai更加智能化,
(刚好今天初见枪龙,一次过,真的爽到。)
这种找到最优路线的算法就是A星算法,接下来本文将详细讲解如何实现A星算法,并用二叉堆优化效率。
什么是A星寻路
A*寻路是一种游戏中常用的寻路算法。使用了一些启发式函数来预测每个节点到目标的距离,并结合当前节点的实际距离来选择下一个节点进行搜索。这种综合考虑实际距离和预估距离的方式,使得A星算法能够更快地找到从起点到目标点的最优路径,因此被广泛应用于实际场景中。
如何实现A星寻路
A星寻路的实现思路如下:
- 初始化:建立网格(Grid2D),节点位置是墙壁的标记为不可行走。
- 将起点设为当前节点并加入到开放列表。
- 从开放列表中选择具有最小F值(F=G+H)的节点,并将其作为当前节点。
- 检查当前节点是否是终点,如果是,则算法结束,从终点回溯到起点找到路径。
- 8方向搜索当前节点的相邻节点。对于每个相邻节点, 如果该相邻节点不可行走/已经在关闭列表里面了,则不进行操作。否则计算其到起点的代价(G)和到终点的估计代价(H),如果相邻节点不在开放列表中,则并将其加入到开放列表,并将相邻节点的父节点设为当前节点;如果相邻节点在开放列表中且新计算的总代价F(G+H)更小,则更新节点的总代价,父节点设为当前节点。
- 将当前节点从开放列表移动到关闭列表。
- 重复步骤3-6,直到起点被找到或者开放列表为空。
节点类
public class Node2D:IComparable<Node2D>
{
public int gCost; //从起点到当前节点的代价
public int hCost; //从当前格子到节点的代价(预估代价)
public bool obstacle; //当前节点是否是障碍物
public Vector3 worldPosition; //当前节点所在的世界坐标
public int GridX, GridY; //节点在二维网格中的坐标
public Node2D parent; //父节点,根据父节点回溯到起点从而找到路径
public Node2D(bool _obstacle, Vector3 _worldPos, int _gridX, int _gridY)
{
obstacle = _obstacle;
worldPosition = _worldPos;
GridX = _gridX;
GridY = _gridY;
}
public int FCost //节点的总代价
{
get
{
return gCost + hCost;
}
}
public int CompareTo(Node2D nodeToCompare)
{
int compare = FCost.CompareTo(nodeToCompare.FCost);
//总代价相同,比较预估代价(选择离终点更近的节点)
if (compare == 0)
{
compare = hCost.CompareTo(nodeToCompare.hCost);
}
return -compare;
}
public void SetObstacle(bool isObstacle)
{
obstacle = isObstacle;
}
}
网格类
public class Grid2D : MonoBehaviour
{
public Vector3 gridWorldSize; //二维网格的大小
public float nodeRadius;//节点半径
public Node2D[,] Grid;
public Tilemap obstaclemap; //障碍物所在tilemap
public List<Node2D> path;
Vector3 worldBottomLeft;//网格的左下角
float nodeDiameter; //节点直径
public int gridSizeX, gridSizeY; //网格width,height
void Awake()
{
nodeDiameter = nodeRadius * 2;
gridSizeX = Mathf.RoundToInt(gridWorldSize.x / nodeDiameter);
gridSizeY = Mathf.RoundToInt(gridWorldSize.y / nodeDiameter);
CreateGrid();
}
void CreateGrid()
{
Grid = new Node2D[gridSizeX, gridSizeY];
worldBottomLeft = transform.position - Vector3.right * gridWorldSize.x / 2 - Vector3.up * gridWorldSize.y / 2;
for (int x = 0; x < gridSizeX; x++)
{
for (int y = 0; y < gridSizeY; y++)
{
Vector3 worldPoint = worldBottomLeft + Vector3.right * (x * nodeDiameter + nodeRadius) + Vector3.up * (y * nodeDiameter + nodeRadius);
Grid[x, y] = new Node2D(false, worldPoint, x, y);
if (obstaclemap.HasTile(obstaclemap.WorldToCell(Grid[x, y].worldPosition)))
Grid[x, y].SetObstacle(true);
else
Grid[x, y].SetObstacle(false);
}
}
}
//gets the neighboring nodes in the 4 cardinal directions. If you would like to enable diagonal pathfinding, uncomment out that portion of code
public List<Node2D> GetNeighbors(Node2D node)
{
List<Node2D> neighbors = new List<Node2D>();
//八方向搜索
for (int i = -1; i <= 1; i++)
{
for (int j = -1; j <= 1; j++)
{
if (i == 0 && j == 0) continue;
//越界
if (node.GridX + i < 0 || node.GridX + i > gridSizeX ||
node.GridY + j < 0 || node.GridY + j > gridSizeY)
{
continue;
}
Node2D neighbourNode = Grid[node.GridX + i, node.GridY + j];
//处于斜角的位置
if (Mathf.Abs(i) == 1 && Mathf.Abs(j) == 1)
{
if (Grid[node.GridX, node.GridY + j].obstacle || Grid[node.GridX + i, node.GridY].obstacle) //斜角的节点的两个邻居节点有任意一个是障碍物,这个点就不能走
{
continue;
}
}
neighbors.Add(neighbourNode);
}
}
return neighbors;
}
//根据世界坐标获取节点
public Node2D NodeFromWorldPoint(Vector3 worldPosition)
{
int x = Mathf.RoundToInt(worldPosition.x + (gridSizeX / 2f));
int y = Mathf.RoundToInt(worldPosition.y + (gridSizeY / 2f));
return Grid[x, y];
}
//Draws visual representation of grid
void OnDrawGizmos()
{
Gizmos.DrawWireCube(transform.position, new Vector3(gridWorldSize.x, gridWorldSize.y, 1));
if (Grid != null)
{
foreach (Node2D n in Grid)
{
if (n.obstacle)
Gizmos.color = Color.red;
else
Gizmos.color = Color.white;
if (path != null && path.Contains(n))
Gizmos.color = Color.black;
Gizmos.DrawCube(n.worldPosition, Vector3.one * (nodeRadius));
}
}
}
}
寻路
public class Pathfinding2D : MonoBehaviour
{
public Transform seeker, target;
Grid2D grid;
Node2D seekerNode, targetNode;
public GameObject GridOwner;
public float interval;
public float timer;
void Start()
{
//Instantiate grid
grid = GridOwner.GetComponent<Grid2D>();
}
private void Update()
{
if (timer < 0f)
{
timer = interval;
FindPath(seeker.transform.position, target.position);
}
else
{
timer -= Time.deltaTime;
}
}
public void FindPath(Vector3 startPos, Vector3 targetPos)
{
//get player and target position in grid coords
seekerNode = grid.NodeFromWorldPoint(startPos); // 起点
targetNode = grid.NodeFromWorldPoint(targetPos); // 终点
// Heap<Node2D> openSet = new Heap<Node2D>(grid.gridSizeX*grid.gridSizeY);
List<Node2D> openSet = new List<Node2D>(); //开放列表
HashSet<Node2D> closedSet = new HashSet<Node2D>(); //关闭列表
openSet.Add(seekerNode); //初始化将起点加入开放列表
//calculates path for pathfinding
while (openSet.Count > 0)
{
Debug.Log(openSet.Count);
//iterates through openSet and finds lowest FCost
Node2D node = openSet[0];
// Node2D node = openSet.RemoveFirst();
for (int i = 1; i < openSet.Count; i++)
{
if (openSet[i].FCost <= node.FCost)
{
if (openSet[i].hCost < node.hCost)
node = openSet[i];
}
}
openSet.Remove(node);
closedSet.Add(node);
//If target found, retrace path
if (node == targetNode)
{
RetracePath(seekerNode, targetNode);
return;
}
//adds neighbor nodes to openSet
foreach (Node2D neighbour in grid.GetNeighbors(node))
{
if (neighbour.obstacle || closedSet.Contains(neighbour))
{
continue;
}
int newCostToNeighbour = node.gCost + GetDistance(node, neighbour);
if (newCostToNeighbour < neighbour.gCost || !openSet.Contains(neighbour))
{
neighbour.gCost = newCostToNeighbour;
neighbour.hCost = GetDistance(neighbour, targetNode);
neighbour.parent = node;
if (!openSet.Contains(neighbour))
openSet.Add(neighbour);
//加上heap
// else
// openSet.UpdateItem(neighbour);
}
}
}
}
//reverses calculated path so first node is closest to seeker
void RetracePath(Node2D startNode, Node2D endNode)
{
List<Node2D> path = new List<Node2D>();
Node2D currentNode = endNode;
while (currentNode != startNode)
{
path.Add(currentNode);
currentNode = currentNode.parent;
}
path.Reverse();
grid.path = path;
}
//gets distance between 2 nodes for calculating cost
int GetDistance(Node2D nodeA, Node2D nodeB)
{
int dstX = Mathf.Abs(nodeA.GridX - nodeB.GridX);
int dstY = Mathf.Abs(nodeA.GridY - nodeB.GridY);
if (dstX > dstY)
return 14 * dstY + 10 * (dstX - dstY);
return 14 * dstX + 10 * (dstY - dstX);
}
}
优化
使用二叉堆可以帮助加速A*算法中的开放列表的维护过程。具体来说,可以使用最小堆来维护开放列表中节点的f值,每次选择f值最小的节点进行扩展,这个f值最小的节点就是根节点。对于堆我们主要有以下几种操作:
- 插入元素。(Add)
- 获取堆中f值最小的节点。(RemoveFirst)
- 堆是否包含当前元素。(Contains)
- 更新堆中元素的f值。(UpdateItem)
(下面的脚本学习自https://www.youtube.com/watch?v=3Dw5d7PlcTM)
//Heap from https://www.youtube.com/watch?v=3Dw5d7PlcTM
//T in this case will be Node
//T : IHeapItem<T> means that the Node has to implement the interface
public class Heap<T> where T : IHeapItem<T>
{
//The array that will hold the heap
private T[] items;
//How many nodes we have stored in the heap
private int currentItemCount;
//How many items can we have in the heap?
public Heap(int maxHeapSize)
{
items = new T[maxHeapSize];
}
//Add new item to the heap
public void Add(T item)
{
//Do we have room to add it?
if (currentItemCount + 1 > items.Length)
{
//Debug.Log("Cant add item to heap becuse it's full");
return;
}
item.HeapIndex = currentItemCount;
//Add the item to the end of the array
items[currentItemCount] = item;
//But it may belong to another position in the heap
SortUp(item);
currentItemCount += 1;
}
//Remove the first item from the heap, which is the node with the lowest f cost
public T RemoveFirst()
{
T firstItem = items[0];
currentItemCount -= 1;
//To resort the heap, we add the last item in the array to the first position in the array
items[0] = items[currentItemCount];
items[0].HeapIndex = 0;
//And then move the first item to where it belongs in the array
SortDown(items[0]);
return firstItem;
}
//How many items do we have in the heap?
public int Count
{
get
{
return currentItemCount;
}
}
//Does the heap contain this item?
public bool Contains(T item)
{
return Equals(items[item.HeapIndex], item);
}
//Update an item already in the heap, but we need to change its priority in the heap
public void UpdateItem(T item)
{
//This is for pathfinding so we only need to add better nodes and thus only need to sort up
SortUp(item); //更新后节点的总代价一定比之前更小,所以做上浮操作
}
//Clear the array
public void Clear()
{
Array.Clear(items, 0, items.Length);
currentItemCount = 0;
}
//Sorts and item down in the array to the position where it belongs
private void SortDown(T item)
{
while (true)
{
//From heap index to array index
int childIndexLeft = item.HeapIndex * 2 + 1;
int childIndexRight = item.HeapIndex * 2 + 2;
int swapIndex = 0;
//Do we have a children to the left
if (childIndexLeft < currentItemCount)
{
swapIndex = childIndexLeft;
//But we also need to check if we have a children to the right
if (childIndexRight < currentItemCount)
{
//Compare the left and the right node, to find if we should swap with the left or the right node
if (items[childIndexLeft].CompareTo(items[childIndexRight]) < 0)//右子节点比左子节点更小,那么交换父元素与右子节点
{
swapIndex = childIndexRight;
}
}
if (item.CompareTo(items[swapIndex]) < 0)
{
Swap(item, items[swapIndex]);
}
else
{
return;
}
}
else
{
return;
}
}
}
//Sorts an item up in the array to the position where it belongs
private void SortUp(T item)
{
//From heap index to array index
int parentIndex = (item.HeapIndex - 1) / 2;
while (true)
{
T parentItem = items[parentIndex];
//If item has a lower f cost than the parent
if (item.CompareTo(parentItem) > 0)
{
Swap(item, parentItem);
}
else
{
break;
}
parentIndex = (item.HeapIndex - 1) / 2;
}
}
//Swap 2 items in the heap, which is the same as moving one item up (or down) and the other item down (or up)
private void Swap(T itemA, T itemB)
{
items[itemA.HeapIndex] = itemB;
items[itemB.HeapIndex] = itemA;
//We also need to swap the heap indexes
int itemAIndex = itemA.HeapIndex;
itemA.HeapIndex = itemB.HeapIndex;
itemB.HeapIndex = itemAIndex;
}
}
//Each node has to implement this, so both HeapIndex and CompareTo
public interface IHeapItem<T> : IComparable<T>
{
int HeapIndex
{
get;
set;
}
}
让节点类(Node2D)继承IHeapItem接口
public class Node2D : IHeapItem<Node2D>
{
public int gCost; //从起点到当前节点的代价
public int hCost; //从当前格子到节点的代价(预估代价)
public bool obstacle; //当前节点是否是障碍物
public Vector3 worldPosition; //当前节点所在的世界坐标
public int GridX, GridY; //节点在二维网格中的坐标
public Node2D parent; //父节点,根据父节点回溯到起点从而找到路径
private int heapIndex;
public Node2D(bool _obstacle, Vector3 _worldPos, int _gridX, int _gridY)
{
obstacle = _obstacle;
worldPosition = _worldPos;
GridX = _gridX;
GridY = _gridY;
}
public int FCost //节点的总代价
{
get
{
return gCost + hCost;
}
}
public int HeapIndex
{
get => heapIndex;
set => heapIndex = value;
}
public int CompareTo(Node2D nodeToCompare)
{
int compare = FCost.CompareTo(nodeToCompare.FCost);
//总代价相同,比较预估代价(选择离终点更近的节点)
if (compare == 0)
{
compare = hCost.CompareTo(nodeToCompare.hCost);
}
return -compare;
}
public void SetObstacle(bool isObstacle)
{
obstacle = isObstacle;
}
}
修改寻路脚本中的PathFinding函数
public void FindPath(Vector3 startPos, Vector3 targetPos)
{
//get player and target position in grid coords
seekerNode = grid.NodeFromWorldPoint(startPos); // 起点
targetNode = grid.NodeFromWorldPoint(targetPos); // 终点
Heap<Node2D> openSet = new Heap<Node2D>(grid.gridSizeX*grid.gridSizeY);//开放列表
HashSet<Node2D> closedSet = new HashSet<Node2D>(); //关闭列表
openSet.Add(seekerNode); //初始化将起点加入开放列表
//calculates path for pathfinding
while (openSet.Count > 0)
{
Debug.Log(openSet.Count);
//iterates through openSet and finds lowest FCost
Node2D node = openSet.RemoveFirst();
closedSet.Add(node);
//If target found, retrace path
if (node == targetNode)
{
RetracePath(seekerNode, targetNode);
return;
}
//adds neighbor nodes to openSet
foreach (Node2D neighbour in grid.GetNeighbors(node))
{
if (neighbour.obstacle || closedSet.Contains(neighbour))
{
continue;
}
int newCostToNeighbour = node.gCost + GetDistance(node, neighbour);
if (newCostToNeighbour < neighbour.gCost || !openSet.Contains(neighbour))
{
neighbour.gCost = newCostToNeighbour;
neighbour.hCost = GetDistance(neighbour, targetNode);
neighbour.parent = node;
if (!openSet.Contains(neighbour))
openSet.Add(neighbour);
//加上heap
else
openSet.UpdateItem(neighbour);
}
}
}
}