系列文章目录
前言
在上一个博客中我们讲述了AStar算法的理论知识,并且编写了AStar算法怎么计算路径,这次我们利用堆栈来进行路径的存储,并且对我们之前编写的代码进行测试,最后我们将把AStar算法应用到NPC身上。
一、AStar算法的测试
我们为了记录NPC移动的时间戳,因此需要定义新的数据结构MovementStep来存储这些数据。
MovementStep脚本的代码如下:
namespace MFarm.AStar
{
public class MovementStep : MonoBehaviour
{
public string sceneName;
public int hour;
public int minute;
public int second;
public Vector2Int gridCoordinate;
}
}
因为NPC可以进行跨场景移动,因此我们需要定义场景名称,同时我们要记录角色在什么时间该到什么地方,我们需要定义时分秒以及网格坐标这些变量。
我们之前已经找到了最短路径了,现在我们就来构建NPC的最短路径,其实就是将路径存入到Stack中,当NPC移动的时候使用即可。
我们在AStar脚本中编写UpdatePathOnMovementStepStack方法。
AStar脚本的UpdatePathOnMovementStepStack方法代码如下:
private void UpdatePathOnMovementStepStack(string sceneName, Stack<MovementStep> npcMovementStep)
{
Node nextNode = targetNode;
while (nextNode != null)
{
MovementStep newStep = new MovementStep();
newStep.sceneName = sceneName;
newStep.gridCoordinate = new Vector2Int(nextNode.gridPosition.x + originX, nextNode.gridPosition.y + originY);
//压入堆栈
npcMovementStep.Push(newStep);
nextNode = nextNode.parentNode;
}
}
这段代码就是我们从终点一次遍历找到其父节点,知道找到起点(节点不为空)即可。我们首先创建一个movementStep的变量newStep,然后对其sceneName和网格坐标进行赋值,并将这个变量压入栈中即可。(这块并没有对时间戳进行赋值,之后开始移动之后才会对时间戳进行赋值)
接着在BuildPath中调用这个方法,我们的AStar脚本就基本上写好了
namespace MFarm.AStar
{
public class AStar : Singleton<AStar>
{
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; //当前选中Node周围的8个点
private HashSet<Node> closedNodeList; //所有被选中的点(用Hash表查找的速度更快)
private bool pathFound;
/// <summary>
/// 构建路径更新Stack的每一步
/// </summary>
/// <param name="sceneName"></param>
/// <param name="startPos"></param>
/// <param name="endPos"></param>
/// <param name="npcMovementStack"></param>
public void BuildPath(string sceneName, Vector2Int startPos, Vector2Int endPos, Stack<MovementStep> npcMovementStack)
{
pathFound = false;
//Debug.Log(endPos);
if (GenerateGridNodes(sceneName, startPos, endPos))
{
//查找最短路径
if (FindShortestPath())
{
//构建NPC移动路径
UpdatePathOnMovementStepStack(sceneName, npcMovementStack);
}
}
}
/// <summary>
/// 构建网格节点信息,初始化两个列表
/// </summary>
/// <param name="sceneName">场景名字</param>
/// <param name="startPos">起点</param>
/// <param name="endPos">终点</param>
/// <returns></returns>
private bool GenerateGridNodes(string sceneName, Vector2Int startPos, Vector2Int endPos)
{
if (GridMapManager.Instance.GetGridDimensions(sceneName, out Vector2Int gridDimensions, out Vector2Int gridOrigin))
{
//根据瓦片地图返回构建网格移动节点范围数组
gridNodes = new GridNodes(gridDimensions.x, gridDimensions.y);
gridWidth = gridDimensions.x;
gridHeight = gridDimensions.y;
originX = gridOrigin.x;
originY = gridOrigin.y;
openNodeList = new List<Node>();
closedNodeList = new HashSet<Node>();
}
else
return false;
//gridNodes的范围是从0,0开始所以需要减去原点坐标得到实际位置
startNode = gridNodes.GetGridNode(startPos.x - originX, startPos.y - originY);
targetNode = gridNodes.GetGridNode(endPos.x - originX, endPos.y - originY);
for (int x = 0; x < gridWidth; x++)
{
for (int y = 0; y < gridHeight; y++)
{
var key = (x + originX) + "x" + (y + originY) + "y" + sceneName;
TileDetails tile = GridMapManager.Instance.GetTileDetails(key);
if (tile != null)
{
Node node = gridNodes.GetGridNode(x, y);
if (tile.isNPCObstacle) node.isObstacle = true;
}
}
}
return true;
}
/// <summary>
/// 找到最短路径所有node添加到closedNodeList
/// </summary>
/// <returns></returns>
private bool FindShortestPath()
{
//添加起点
openNodeList.Add(startNode);
while(openNodeList.Count > 0)
{
//节点排序,Node内涵比较函数
openNodeList.Sort();
Node closeNode = openNodeList[0];
openNodeList.RemoveAt(0);
closedNodeList.Add(closeNode);
if (closeNode == targetNode)
{
pathFound = true;
break;
}
//计算周围8个Node补充到OpenList
EvaluateNeighbourNodes(closeNode);
}
return pathFound;
}
/// <summary>
/// 评估周围八个点并得到消耗值
/// </summary>
/// <param name="currentNode"></param>
private void EvaluateNeighbourNodes(Node currentNode)
{
Vector2Int currentNodePos = currentNode.gridPosition;
Node validNeighbourNode;
for (int x = -1; x <= 1; x++)
{
for (int y = -1; y <= 1; y++)
{
if (x == 0 && y == 0) continue;
validNeighbourNode = GetVaildNeighbourNode(currentNodePos.x + x, currentNodePos.y + y);
if (validNeighbourNode != null)
{
if(!openNodeList.Contains(validNeighbourNode))
{
validNeighbourNode.gCost = currentNode.gCost + GetDistance(currentNode, validNeighbourNode);
validNeighbourNode.hCost = GetDistance(validNeighbourNode, targetNode);
//连接父节点
validNeighbourNode.parentNode = currentNode;
openNodeList.Add(validNeighbourNode);
}
}
}
}
}
private Node GetVaildNeighbourNode(int x, int y)
{
if (x >= gridWidth || y >= gridHeight || x < 0 || y < 0) return null;
Node neighbourNode = gridNodes.GetGridNode(x, y);
if (neighbourNode.isObstacle || closedNodeList.Contains(neighbourNode))
return null;
else
return neighbourNode;
}
/// <summary>
/// 返回任意两点的距离值
/// </summary>
/// <param name="nodeA"></param>
/// <param name="nodeB"></param>
/// <returns>14的倍数 + 10的倍数</returns>
private int GetDistance(Node nodeA, Node nodeB)
{
int xDistance = Mathf.Abs(nodeA.gridPosition.x - nodeB.gridPosition.x);
int yDistance = Mathf.Abs(nodeA.gridPosition.y - nodeB.gridPosition.y);
if (xDistance > yDistance)
{
return 14 * yDistance + 10 * (xDistance - yDistance);
}
return 14 * xDistance + 10 * (yDistance - xDistance);
}
private void UpdatePathOnMovementStepStack(string sceneName, Stack<MovementStep> npcMovementStep)
{
Node nextNode = targetNode;
while (nextNode != null)
{
MovementStep newStep = new MovementStep();
newStep.sceneName = sceneName;
newStep.gridCoordinate = new Vector2Int(nextNode.gridPosition.x + originX, nextNode.gridPosition.y + originY);
//压入堆栈
npcMovementStep.Push(newStep);
nextNode = nextNode.parentNode;
}
}
}
}
我们现在就可以进行一些测试了,我们创建一个空物体作为控制器NPCManager,并将该控制器挂在AStar脚本,然后编写AStarTest脚本(也挂在AStar脚本上),用于编写测试代码。
AStarTest脚本代码如下:
namespace MFarm.AStar
{
public class AStarTest : MonoBehaviour
{
private AStar aStar;
[Header("用于测试")]
public Vector2Int startPos;
public Vector2Int finishPos;
public Tilemap displayMap;
public TileBase displayTile;
public bool displayStartAndFinish;
public bool displayPath;
private Stack<MovementStep> npcMovmentStepStack;
private void Awake()
{
aStar = GetComponent<AStar>();
npcMovmentStepStack = new Stack<MovementStep>();
}
private void Update()
{
ShowPathOnGridMap();
}
private void ShowPathOnGridMap()
{
if (displayMap != null && displayTile != null)
{
if (displayStartAndFinish)
{
displayMap.SetTile((Vector3Int)startPos, displayTile);
displayMap.SetTile((Vector3Int)finishPos, displayTile);
}
else
{
displayMap.SetTile((Vector3Int)startPos, null);
displayMap.SetTile((Vector3Int)finishPos, null);
}
if (displayPath)
{
var sceneName = SceneManager.GetActiveScene().name;
aStar.BuildPath(sceneName, startPos, finishPos, npcMovmentStepStack);
foreach (var step in npcMovmentStepStack)
{
displayMap.SetTile((Vector3Int)step.gridCoordinate, displayTile);
}
}
else
{
if (npcMovmentStepStack.Count > 0)
{
foreach (var step in npcMovmentStepStack)
{
displayMap.SetTile((Vector3Int)step.gridCoordinate, null);
}
npcMovmentStepStack.Clear();
}
}
}
}
}
}
我们现在来解释这一段代码,首先我们需要定义一些变量,例如aStar脚本的声明aStar,起始点startPos和终点finishPos;接着我们需要在PersistentScene场景中创建一个新的TileMap,用于绘制最短路径(displayMap);接着我们还需要声明可以展示的瓦片displayTile(需要自己制作,教程中使用的是红色的瓦片,显眼颜色的均可),然后定义两个bool值,当我们勾选之后,才会显示起始点和终点displayStartAndFinish,或者显示最短路径displayPath,最后需要一个用于存储移动路径的栈npcMovmentStepStack。
我们在Awake方法中拿到aStar变量,并且声明一个新的栈。
接着我们要编写ShowPathOnGridMap方法,这个方法首先需要判断displayMap和displayTile是否为空,如果不为空才能执行接下来的代码,如果displayStartAndFinish为true,那么将起始点和终点的网格改为我们想要修改成的网格,利用SetTile方法;如果displayPath,我们获得当前场景的场景名(测试只是在同场景中进行),然后利用AStar脚本的BuildPath方法将路径存储到栈中,我们把堆栈中的每一个网格都利用SetTile将其变成想要的网格,如果我们不进行显示,我们只需要将displayMap清空即可,最后清空栈。
我们打开Unity,进行测试,发现已经实现了找到最短路径的功能,但是问题就是当前的场景并没有设置障碍,因此我们需要对两个场景进行一些障碍的设置(Grid Properities - > NPC Obstacle),绘制我们想要的范围即可。
绘制完成之后我们就可以观察在有障碍的环境中的最短路径是如何绘制的了。
二、创建NPC信息并实现根据场景切换显示
和创建我们的主角相似,我们拿到NPC的Sprite后,为其添加Box Collider 2D(勾选IsTrigger),接着添加刚体组件,以实现角色的移动(关掉重力,锁定角色的Z轴,同时更改Collision Detection更改为Continuous,Sleeping Mode改为Never Sleep,Interpolate改为Extrapolate,这样做的目的应该是是的角色和NPC在移动过程中不会穿过彼此,同时禁用碰撞体的睡眠,虽然很影响系统资源,并根据下一帧移动的估计位置来实现平滑移动);
关于RigidBody2D的详细属性也可以去查看Unity手册:https://docs.unity.cn/cn/2020.3/Manual/class-Rigidbody2D.html#:~:text=2D%20%E5%88%9A%E4%BD%93%20(Rig
接着我们给NPC添加Animator,实现其动画的切换,动画的制作过程此处就不说明了,因为本篇博客的重点还是AStar算法的实现。
我们还要为NPC添加影子的子物体,切记设置其层以及层级顺序。
我们继续创建NPC的移动脚本NPCMovement,并将该脚本挂载到NPC上,接下来我们可以编写NPCMovement的脚本啦。
为了方便代码的讲解,我们先把代码给展示出来,然后对着代码去将每一步要干什么。
using System.Collections;
using System.Collections.Generic;
using MFarm.AStar;
using UnityEngine;
using UnityEngine.SceneManagement;
[RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(Animator))]
public class NPCMovement : MonoBehaviour
{
//临时存储信息
[SerializeField] private string currentScene;
private string targetScene;
private Vector3Int currentGridPosition;
private Vector3Int tragetGridPosition;
public string StartScene { set => currentScene = value; }
[Header("移动属性")]
public float normalSpeed = 2f;
private float minSpeed = 1;
private float maxSpeed = 3;
private Vector2 dir;
public bool isMoving;
//Components
private Rigidbody2D rb;
private SpriteRenderer spriteRenderer;
private BoxCollider2D coll;
private Animator anim;
private Stack<MovementStep> movementSteps;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
spriteRenderer = GetComponent<SpriteRenderer>();
coll = GetComponent<BoxCollider2D>();
anim = GetComponent<Animator>();
}
private void OnEnable()
{
EventHandler.AfterSceneLoadedEvent += OnAfterSceneLoadedEvent;
}
private void OnDisable()
{
EventHandler.AfterSceneLoadedEvent -= OnAfterSceneLoadedEvent;
}
private void OnAfterSceneLoadedEvent()
{
CheckVisiable();
}
private void CheckVisiable()
{
if (currentScene == SceneManager.GetActiveScene().name)
SetActiveInScene();
else
SetInactiveInScene();
}
#region 设置NPC显示情况
private void SetActiveInScene()
{
spriteRenderer.enabled = true;
coll.enabled = true;
//TODO:影子关闭
// transform.GetChild(0).gameObject.SetActive(true);
}
private void SetInactiveInScene()
{
spriteRenderer.enabled = false;
coll.enabled = false;
//TODO:影子关闭
// transform.GetChild(0).gameObject.SetActive(false);
}
#endregion
}
首先,因为我们这个脚本是要挂载在所有NPC脚本上的,那么我们必须要该脚本所挂载的物体必须添加了刚体2D组件和Animator组件;接着我们要定义一些变量来存储NPC的信息,起始场景currentScene和目标场景targetScene,起始位置currentGridPosition和目标位置targetGridPosition以及设置一个变量StartScene对初始场景进行赋值;然后定义一些移动属性变量,例如NPC的移动速度normalSpeed,以及其移动速度的上下限minSpeed和maxSpeed(因为NPC在移动时会进行斜方向的移动,或者在某些NPC的移动过程中需要加速和减速,因此我们需要定义其最大速度和最小速度,使角色移动不至于太快或者太慢);最后为了实现动画状态机的动画切换,我们还要定义一个Vector2类型的变量dir和一个bool类型的变量isMoving。这样我们部分变量就算是创建完了。
接着创建脚本NPCManager,并挂载在NPCManager物体上。通过这个脚本我们想要获取到当前脚本中所以的NPC并对其初始场景和初始坐标进行赋值,因此我们还需要创建一个新的类型,方便对上述变量进行赋值。
这个变量首先需要获取角色的Transform,所在场景和所在位置。
DataCollection脚本新建的变量类型NPCPosition
[System.Serializable]
public class NPCPosition
{
public Transform npc;
public string startScene;
public Vector3 position;
}
我们在NPCManager脚本中添加list来存储所有的NPC,返回Unity界面,将我们场景中的NPC拖拽过来即可。
接着我们返回NPCManager脚本中,我们现在要添加一些变量,例如:刚体(控制NPC移动),SpriteRenderer(因为NPC始终存在在场景中,所以NPC是始终都存在的,那么当角色从第一个场景跨越到第二个场景后,我们在视觉上直接关闭NPC的SpriteRenderer即可),碰撞体,存储移动网格的堆栈以及动画控制器,并在Awake中赋值。
由于我们要控制SpriteRenderer的关闭和打开,那么我们接下来编写两个方法,控制SpriteRenderer的这两个操作。
#region 设置NPC显示情况
private void SetActiveInScene()
{
spriteRenderer.enabled = true;
coll.enabled = true;
//TODO:影子关闭
transform.GetChild(0).gameObject.SetActive(true);
}
private void SetInactiveInScene()
{
spriteRenderer.enabled = false;
coll.enabled = false;
//TODO:影子关闭
transform.GetChild(0).gameObject.SetActive(false);
}
#endregion
那么这些方法应该怎么被调用嘞,那么我们首先编写一个方法,调用SetActiveInScene和SetInactiveInScene方法。(代码如下)
private void CheckVisiable()
{
if (currentScene == SceneManager.GetActiveScene().name)
SetActiveInScene();
else
SetInactiveInScene();
}
那么这个方法又应该在什么位置调用呢,应该是在跳转场景之后,我们判断此时NPC是否可以在当前场景中进行显示,所以我们注册AfterSceneLoadedEvent事件,并在OnAfterSceneLoadedEvent方法中调用CheckVisiable。
现在就可以返回Unity测试,由于我们的初始场景为01.Field,我们将角色的currentScene手动更改为02.Home,运行,我们就会发现在当前场景中没有找到NPC。