视频展示:unity农场游戏(包含背包、种植系统及npc使用A*寻路)_哔哩哔哩bilibili_演示
目录
最终效果预览
算法介绍
A*(A-star)算法是一种静态网路中求解最短路径最有效的直接搜索算法。在电子游戏中最主要的应用是寻找地图上两点间的最佳路线。
算法思想
类比广度优先搜索算法,其思想都是先遍历周围的点,然后,不同之处在于,A*算法具有一个求解最优点的过程。第一次遍历完,会将周围这些点存入一个OpenList中,然后选出一个代价最小的。代价分为两部分。
FCost,即当前代价,代表的是从起点到当前点所需要的路径。
GCost,即预估代价,代表的是从当前点到终点所需要的路径。
我们选出最优点,就是通过判断这两者之和谁是最小的。我们称之为HCost。
具体算法实现
首先承接在上文2中,做出了一个瓦片地图,并且每个点都有对应的坐标,这是实现A*算法所需要的网格。
接下来书写单个普通点的脚本,其包含几个变量:
public Vector2Int gridPosition; //网格坐标
public int gCost = 0; //距离Start格子的距离
public int hCost = 0; //距离Target格子的距离
public int FCost => gCost + hCost; //当前格子的值
public bool isObstacle = false; //当前格子是否是障碍
public Node parentNode;//路径中的前一个格子
初始化函数:
public Node(Vector2Int pos)
{
gridPosition = pos;
parentNode = null;
}
由于后续我们需要对点进行排序,排序的方式是通过让这个类继承IComprable接口,这样可以方便排序:
书写排序函数,当前结点和其他结点进行比较,如果小于返回-1,相等返回0,大于返回1。
在FCost相等的情况下,返回hCost小的,也就是看谁离终点更近。
public int CompareTo(Node other)
{
//比较选出最低的F值,返回-1,0,1
int result = FCost.CompareTo(other.FCost);
if (result == 0)
//FCost相同的情况下,判断谁的hcost最小,即谁距离终点最近
{
result = hCost.CompareTo(other.hCost);
}
return result;
}
接下来书写一个包含网格中所有结点的类GridNode,其包含整个地图的宽度高度、以及一个由点组成的二维矩阵:
public class GridNodes
{
private int width;
private int height;
private Node[,] gridNode;
然后根据宽度和高度初始化点组成的二维矩阵:
/// <summary>
/// 构造函数初始化节点范围数组
/// </summary>
/// <param name="width">地图宽度</param>
/// <param name="height">地图高度</param>
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));
}
}
}
书写一个可以根据x、y坐标得到结点的方法:
public Node GetGridNode(int xPos, int yPos)
{
if (xPos < width && yPos < height)
{
return gridNode[xPos, yPos];
}
Debug.Log("超出网格范围");
return null;
}
接下来书写A*算法的代码,其将会被挂载在manager上,用于操控所有npc的寻路。当npc需要寻路时就使用这个代码来实现。其包含初始点和目标点,和具体的寻路算法。
首先需要这些基础变量:
private GridNodes gridNodes;
private Node startNode;
private Node targetNode;
private int gridWidth;//网格宽度
private int gridHeight;
private int originX;
private int originY;
其中originX和originY,是因为由于瓦片地图中的坐标会有负数,而储存在二维数组里是不能有负数的,所以需要通过加上一个初始的偏移数才能进行存储。
在A*算法使用之前,需要先对结点中的信息进行初始化,首先我们在inspector窗口中将长宽和初始左下角原点填写到SO中,
由网格地图管理器GridMapManager传给A*算法初始化,给出的返回值为true代表有该地图的信息。
/// <summary>
/// 根据场景名字构建网格范围,输出范围和原点
/// </summary>
/// <param name="sceneName">场景名字</param>
/// <param name="gridDimensions">网格范围</param>
/// <param name="gridOrigin">网格原点</param>
/// <returns>是否有当前场景的信息</returns>
public bool GetGridDimensions(string sceneName, out Vector2Int gridDimensions, out Vector2Int gridOrigin)
{
gridDimensions = Vector2Int.zero;
gridOrigin = Vector2Int.zero;
foreach (var mapData in mapDataList)
{
if (mapData.sceneName == sceneName)
{
gridDimensions.x = mapData.gridWidth;
gridDimensions.y = mapData.gridHeight;
gridOrigin.x = mapData.originX;
gridOrigin.y = mapData.originY;
return true;
}
}
return false;
}
}
接下来由A*算法初始化:
其中还包括初始化两个列表,这两个列表在后续执行算法会用上。
/// <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);
}
光是初始化上述信息还不够,我们还需要判断该地图中的某个点是否为障碍。
那么如何判断呢?这需要我们事前生成好地图信息,对会是障碍的地方用瓦片画出来并储存下信息:
该信息会被储存在SO类型的MapData中:
于是,接下来在上述初始化的表格中添加如下代码用于判断该点是否是障碍:
for (int x = 0; x < gridWidth; x++)
{
for (int y = 0; y < gridHeight; y++)
{
Vector3Int tilePos = new Vector3Int(x + originX, y + originY, 0);
//拿到对应的瓦片
//TileDetails tile = GridMapManager.Instance.GetTileDetailsOnMousePosition(tilePos);
//这种写法没有考虑场景
var key = tilePos.x + "x" + tilePos.y + "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;
返回值为true或者false代表的是网格信息是否创建成功。
接下来来实现寻找最短路径的方法,初始的思路很简单,首先将起点加入OpenList中,接下来我们考虑每次循环要做的事情,首先就是在OpenList中存储着的结点排序,找出代价最小的。
然后将其移出OpenList,并加入CloseList,并判断该结点是否为终点,如果是则说明找到最短路径。
如果不是则我们需要将该结点周围的结点加入到OpenList中(在执行该函数时会为这些结点设定好FCost和GCost)。
就这样循环直到找到终点或者OpenList中已经没有结点了。
加入到OpenList的结点代表的是待评估距离的结点,每次在这里面寻找出最短路径的结点。
ClosedList里面的结点是在每轮循环中曾经被选出来的,可能是最短路径上的结点。
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;
}
EvaluateNeighbourNodes(closeNode);
//计算周围8个Node补充到OpenList
}
return pathFound;
}
返回true和false代表的是是否找到最短路径。
其中EvaluateNeighbourNodes(closeNode); 则是计算周围8个结点并添加到OpenList中。
接下来书写这个方法。
在获取这8个点之前需要判断这些点是否有效,如果超出边界则不行,如果是障碍则不行,如果已经加入到列表中也不行,判断有效的方法:
/// <summary>
/// 找到有效的Node,非障碍,非已选择
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <returns></returns>
private Node GetValidNeighbourNode(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;
}
除此之外,还需要一个用于计算两点间距离的函数,用于后续生成代价:
注意,对于斜着的距离我们认为是根号2,也就是1.4,为了方便整数运算,我们设置为14。
/// <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);
}
上面这个函数的计算结果示例:
有了这个判断是否有效的方法后,接下来就获取周围8个点。获取这8个点的方法是通过加上自身的水平和竖直距离的-1到1,来实现的。
下面的代码,在判断该点是否有效后,且openList中不包含该点后,则将该点计算其代价,然后加入OpenList。
注意还有一步是用来链接其父结点,是用于后续寻找最短路径用的!
/// <summary>
/// 评估周围8个点,并生成对应的代价值
/// </summary>
/// <param name="currentNode"></param>
private void EvaluateNeighbourNodes(Node currentNode)
{
Vector2Int currentNodePos = currentNode.gridPosition;
Node validNeighbourNode;
//循环周围的8个点,通过水平方向和竖直方向偏移-1到1可以得到
for (int x = -1; x <= 1; x++)
{
for (int y = -1; y <= 1; y++)
{
if (x == 0 && y == 0)
continue;
validNeighbourNode = GetValidNeighbourNode(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);
}
}
}
}
}
在生成完地图,查找完最短路径后,就可以构建路径了。
/// <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;
if (GenerateGridNodes(sceneName, startPos, endPos))
{
//查找最短路径
if (FindShortestPath())
{
UpdatePathOnMovementStepStack(sceneName, npcMovementStack);
//构建NPC移动路径}
}
}
}
我们如何构建NPC的移动路径呢?
我们希望npc是一步一步走的,那么我们需要存储每一步是如何走的,所以需要用一个数据结构来进行存储。
由于我们是从起点一步步往后走走到终点,那我们回推路径的时候就是从尾部往回不断推,(通过链接父节点往回退)。栈的特点是先进后出所以此处使用栈。
每一步走的路径,我们新建一个类来存储:
public class MovementStep
{
public string sceneName;
public int hour;
public int minute;
public int second;
public Vector2Int gridCoordinate;
}
有场景名字,是为了后续的跨场景移动。有小时、分钟、秒是因为我们让npc移动,需要让npc在指定的时刻走到哪一步。并且后续需要让npc在指定的时间移动。
下面完善前面用到的UpdatePathOnMovementStepStack函数,通过一步步回推父节点来实现找到路径。
/// <summary>
/// 更新路径每一步的坐标和场景名字
/// </summary>
/// <param name="sceneName"></param>
/// <param name="npcMovementStep"></param>
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;
}
}
这样,到此为止就找到了最短路径。
A*算法完整代码
using System.Collections;
using System.Collections.Generic;
using MFarm.Map;
using UnityEngine;
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; //所有被选中的点
private bool pathFound;
/// <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++)
{
Vector3Int tilePos = new Vector3Int(x + originX, y + originY, 0);
//拿到对应的瓦片
//TileDetails tile = GridMapManager.Instance.GetTileDetailsOnMousePosition(tilePos);
//这种写法没有考虑场景
var key = tilePos.x + "x" + tilePos.y + "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>
/// 构建路径更新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;
if (GenerateGridNodes(sceneName, startPos, endPos))
{
//查找最短路径
if (FindShortestPath())
{
UpdatePathOnMovementStepStack(sceneName, npcMovementStack);
//构建NPC移动路径}
}
}
}
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;
}
EvaluateNeighbourNodes(closeNode);
//计算周围8个Node补充到OpenList
}
return pathFound;
}
/// <summary>
/// 评估周围8个点,并生成对应的代价值
/// </summary>
/// <param name="currentNode"></param>
private void EvaluateNeighbourNodes(Node currentNode)
{
Vector2Int currentNodePos = currentNode.gridPosition;
Node validNeighbourNode;
//循环周围的8个点,通过水平方向和竖直方向偏移-1到1可以得到
for (int x = -1; x <= 1; x++)
{
for (int y = -1; y <= 1; y++)
{
if (x == 0 && y == 0)
continue;
validNeighbourNode = GetValidNeighbourNode(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);
}
}
}
}
}
/// <summary>
/// 找到有效的Node,非障碍,非已选择
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <returns></returns>
private Node GetValidNeighbourNode(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);
}
/// <summary>
/// 更新路径每一步的坐标和场景名字
/// </summary>
/// <param name="sceneName"></param>
/// <param name="npcMovementStep"></param>
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;
}
}
}
让NPC寻路
接下来可以做一个测试,为了测试我们的结果是否正确。这个测试就是设定一个初始点和终点,我们通过将路径画出来的方式判断是否正确:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Tilemaps;
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();
}
}
}
}
}
}
当我们选取起点和终点时,并且中间有障碍物
接下来实现让NPC按照指定的路径走
创建一个npc,勾选Animator选项、rigidBody2d,并修改一些设定
创建NPC的动画器,使用融合树创建其动画。
过程比较简单此处不赘述。
接下来创建一个NPCManager,其含义一个NPCPositon的变量的List,用于存储所有NPC的相关信息。
public class NPCPosition
{
public Transform npc;
public string startScene;
public Vector3 position;
}
public class NPCManager : MonoBehaviour
{
public List<NPCPosition> npcPositionList;
}
下面书写具体的NPC相关的代码:
为每个NPC设定他应该在的位置和场景,只有当玩家处于该场景时才将它设置为可见。
需要获取npc身上的一些控制器组件,以及对其判定是否可见。
当场景加载完毕时,我们要设置其不可见。
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
}
我们希望人物处于网格正中心,这样方便后续的移动,所以调用这个函数:
private void InitNPC()
{
targetScene = currentScene;
//保持在当前坐标的网格中心点
currentGridPosition = grid.WorldToCell(transform.position);
transform.position = new Vector3(currentGridPosition.x + Settings.gridCellSize / 2f, currentGridPosition.y + Settings.gridCellSize / 2f, 0);
tragetGridPosition = currentGridPosition;
}
只有第一次加载场景时,我们才需要这么做:因此在场景加载完的事件添加如下代码:
private void OnAfterSceneLoadedEvent()
{
grid=FindObjectOfType<Grid>();
CheckVisiable();
if (!isInitialised)
{
InitNPC();
isInitialised = true;
}
接下来继续完善NPC的相关逻辑。
使用一个新的类来记录npc的路程的细节(需要包括时间相关、优先级)
有时间是为了让npc在指定的时间移动到指定的位置,有场景名字是为了后续的跨场景移动。动画是为了后续的人物移动完毕后执行的动画。
using System.Collections;
using System.Collections.Generic;
using System;
using UnityEngine;
[Serializable]
public class ScheduleDetails : IComparable<ScheduleDetails>
{
public int hour, minute, day;
public int priority; //优先级越小优先执行
public Season season;
public string targetScene;
public Vector2Int targetGridPosition;
public AnimationClip clipAtStop;
public bool interactable;
public ScheduleDetails(int hour, int minute, int day, int priority, Season season, string targetScene, Vector2Int targetGridPosition, AnimationClip clipAtStop, bool interactable)
{
this.hour = hour;
this.minute = minute;
this.day = day;
this.priority = priority;
this.season = season;
this.targetScene = targetScene;
this.targetGridPosition = targetGridPosition;
this.clipAtStop = clipAtStop;
this.interactable = interactable;
}
public int Time => (hour * 100) + minute;
public int CompareTo(ScheduleDetails other)
{
if (Time == other.Time)
{
if (priority > other.priority)
return 1;
else
return -1;
}
else if (Time > other.Time)
{
return 1;
}
else if (Time < other.Time)
{
return -1;
}
return 0;
}
}
然后创建一个和这相关的SO:
为npc添加日程的SO,和将其储存到Set里面:
将A*变为单例模式,然后我们在test脚本中就可以去调用A*的算法,生成路径。
/// <summary>
/// 根据Schedule构建路径
/// </summary>
/// <param name="schedule"></param>
public void BuildPath(ScheduleDetails schedule)
{
movementSteps.Clear();
currentSchedule = schedule;
tragetGridPosition = (Vector3Int)schedule.targetGridPosition;
if (schedule.targetScene == currentScene)
{
AStar.Instance.BuildPath(schedule.targetScene, (Vector2Int)currentGridPosition, schedule.targetGridPosition, movementSteps);
}
}
但是这样只生成了路径,我们希望,接下来,我们希望知道npc走到具体每一步时,应该是几分几秒。这样可以让npc按照固定的速度行走,并且也有助于后续安排npc按照日程安排去行走。
在上面的代码中添加如下判断:去更新每一个步骤对应的时间
if (movementSteps.Count > 1)
{
//更新每一步对应的时间戳
UpdateTimeOnPath();
}
更新每一步的时间的方法如下所示,首先计算出距离和时间,得到每一步所需移动的时间(其实就是分斜格子和水平移动的格子,这两种格子移动时间不同)。
然后每一步的时间加上之前的时间即可。
/// <summary>
/// 更新了每一步,走到那个路径时应该是几分几秒
/// </summary>
private void UpdateTimeOnPath()
{
MovementStep previousStep = null;
TimeSpan currentGameTime = GameTime;
foreach (MovementStep step in movementSteps)
{
if (previousStep == null)
previousStep = step;
step.hour = currentGameTime.Hours;
step.minute = currentGameTime.Minutes;
step.second = currentGameTime.Seconds;
TimeSpan gridMovementStepTime;//走过一个格需要花这么多的时间
if (MoveInDiagonal(step, previousStep))
gridMovementStepTime = new TimeSpan(0, 0, (int)(Settings.gridCellDiagonalSize / normalSpeed / Settings.secondThreshold));
else
gridMovementStepTime = new TimeSpan(0, 0, (int)(Settings.gridCellSize / normalSpeed / Settings.secondThreshold));
//累加获得下一步的时间戳
currentGameTime = currentGameTime.Add(gridMovementStepTime);
//循环下一步
previousStep = step;
}
}
判断是否走的是斜方向使用这个方法:
/// <summary>
/// 判断是否走斜方向
/// </summary>
/// <param name="currentStep"></param>
/// <param name="previousStep"></param>
/// <returns></returns>
private bool MoveInDiagonal(MovementStep currentStep, MovementStep previousStep)
{
//如果x和y都不相等,说明走的是斜方向的
return (currentStep.gridCoordinate.x != previousStep.gridCoordinate.x) && (currentStep.gridCoordinate.y != previousStep.gridCoordinate.y);
}
接下来有了路径和每一步应该移动到几分几秒,就可以实现npc的实际移动了。思路如下:
使用movement函数,并且保证一次只移动一个格子。获取每个下一步要移动的格子,以及其时间,然后将参数传给实际移动一个格子的函数:
实际移动一个格子的函数要做的事情:
实际用来移动的协程,首先计算出需要移动的时间和距离,那么就可以得出实际位移的速度应该是多大。
然后接下来让它每一帧去移动一段距离,即可实现。
具体如何移动,是先通过下一个将要移动到的格子减去当前格子即可得到位移向量,然后乘以速度即可。然后使用yield return保证每一帧执行一次即可。
如果时间已到,则让该npc瞬移到该位置。
最后再在FixedUpdate中执行该movement函数。
完整代码如下:
private void FixedUpdate()
{
Movement();
}
/// <summary>
/// 使用npcMove的机制保证只有移动到了下一个格子才会重新移动新的格子
/// </summary>
private void Movement()
{
if (!npcMove)
{
if (movementSteps.Count > 0)
{
MovementStep step = movementSteps.Pop();
currentScene = step.sceneName;
CheckVisiable();//判断是否在当前场景,如果不是在当前场景则设为不可见。
//下一步的的网格坐标
nextGridPosition = (Vector3Int)step.gridCoordinate;
TimeSpan stepTime = new TimeSpan(step.hour, step.minute, step.second);
MoveToGridPosition(nextGridPosition, stepTime);
}
}
}
private void MoveToGridPosition(Vector3Int gridPos, TimeSpan stepTime)
{
//因为此处这个函数后续还需要补充代码,所以将该协程放到这个函数里面
StartCoroutine(MoveRoutine(gridPos, stepTime));
}
/// <summary>
///
/// </summary>
/// <param name="gridPos"></param>
/// <param name="stepTime"></param>
/// <returns></returns>
private IEnumerator MoveRoutine(Vector3Int gridPos, TimeSpan stepTime)
{
npcMove = true;
nextWorldPosition = GetWorldPostion(gridPos);
//还有时间用来移动
if (stepTime > GameTime)
{
//用来移动的时间差,以秒为单位
float timeToMove = (float)(stepTime.TotalSeconds - GameTime.TotalSeconds);
//实际移动距离
float distance = Vector3.Distance(transform.position, nextWorldPosition);
//实际移动速度
float speed = Mathf.Max(minSpeed, (distance / timeToMove / Settings.secondThreshold));
if (speed <= maxSpeed)
{
while (Vector3.Distance(transform.position, nextWorldPosition) > Settings.pixelSize)
{
dir = (nextWorldPosition - transform.position).normalized;
Vector2 posOffset = new Vector2(dir.x * speed * Time.fixedDeltaTime, dir.y * speed * Time.fixedDeltaTime);
rb.MovePosition(rb.position + posOffset);
yield return new WaitForFixedUpdate();
}
}
}
//如果时间已经到了就瞬移
rb.position = nextWorldPosition;
currentGridPosition = gridPos;
nextGridPosition = currentGridPosition;
npcMove = false;
}
/// <summary>
/// 给定网格坐标返回世界坐标中心点
/// </summary>
/// <param name="gridPos"></param>
/// <returns></returns>
private Vector3 GetWorldPostion(Vector3Int gridPos)
{
Vector3 worldPos = grid.CellToWorld(gridPos);
return new Vector3(worldPos.x + Settings.gridCellSize / 2f, worldPos.y + Settings.gridCellSize / 2);
}
由于我们不希望在场景加载时,npc也能移动,所以使用一个bool变量来限制。
首先使用相应的事件修改其值:
在AStarTest中添加如下代码即可测试npc的运动:
[Header("测试移动NPC")]
public NPCMovement npcMovement;
public bool moveNPC;
public string targetScene;
public Vector2Int targetPos;
public AnimationClip stopClip;
private void Awake()
{
aStar = GetComponent<AStar>();
npcMovmentStepStack = new Stack<MovementStep>();
}
private void Update()
{
ShowPathOnGridMap();
if (moveNPC)
{
moveNPC = false;
var schedule = new ScheduleDetails(0, 0, 0, 0, Season.春天, targetScene, targetPos, stopClip, true);
npcMovement.BuildPath(schedule);
}
}
效果如下:
接下来为npc添加上动画,很简单,只需要在update中执行一个可以将参数传给动画控制器的函数即可。
private void SwitchAnimation()
{
isMoving = transform.position != GetWorldPostion(tragetGridPosition);
anim.SetBool("isMoving", isMoving);
if (isMoving)
{
anim.SetBool("Exit", true);
anim.SetFloat("DirX", dir.x);
anim.SetFloat("DirY", dir.y);
}
else
{
anim.SetBool("Exit", false);
}
}
实现寻路后,我们希望实现人物导航完毕后可以执行一些动画。
设置一个动画计时器,防止在movement函数中不断执行协程。
当时间到时,则设置可以播放停止动画的变量为true。
然后我们设定,当玩家不处于移动状态,并且每隔一段时间就让其执行停止动画的协程并播放动画:
想要改变此时npc的动画,就需要去实时的改变其动画控制器里的动画:
操作方法如下:创建一个override controller,然后去获取当前的控制器用来创建,再用创建好的反向赋值。后面去修改这个override controller的实时的动画,就可以了。
private IEnumerator SetStopAnimation()
{
//强制面向镜头
anim.SetFloat("DirX", 0);
anim.SetFloat("DirY", -1);
animationBreakTime = Settings.animationBreakTime;
if (stopAnimationClip != null)
{
Debug.Log("stopAnimationClip");
animOverride[blankAnimationClip] = stopAnimationClip;
anim.SetBool("EventAnimation", true);//播放动画
yield return null;
anim.SetBool("EventAnimation", false);//下一帧时将该变量设置为false,防止后续保持true的状态则状态机再次进入
}
else
{
animOverride[stopAnimationClip] = blankAnimationClip;
anim.SetBool("EventAnimation", false);
}
}
NPC的日程安排
接下来实现让npc在指定时间做指定的事件,也就是日程安排。
首先在设置好的日程的SO这里,添加做什么事的时间还有地点。
然后将上面的数据在代码里面储存在Set中方便根据时间排序:
随后添加分钟改变时会执行的事件:在该事件中,首先会判断当前时间是否与已有的日程安排的时间相同。
如果时间相同,则获取该计划schedule,然后用该schedule去创造路径。接下来执行移动该路径即可。
private void OnGameMinuteEvent(int minute, int hour, int day, Season season)
{
int time = (hour * 100) + minute;
//currentSeason = season;
ScheduleDetails matchSchedule = null;
//通过遍历的方法直到找到匹配的schedule
foreach (var schedule in scheduleSet)
{
if (schedule.Time == time)
{
if (schedule.day != day && schedule.day != 0)
continue;
if (schedule.season != season)
continue;
matchSchedule = schedule;
}
else if (schedule.Time > time)
{
break;
}
}
if (matchSchedule != null)
BuildPath(matchSchedule);//这样就可以根据指定的计划进行指定的动作了
}
实现NPC的跨场景移动
跨场景移动的路径由两部分组成,一个是从当前点到传送门的位置,另一部分是从另一个场景的传送门到该场景的目标点的位置。
创建一个场景路径的path,包含场景名字,从哪里走到哪里。
然后创建一个用上面的那个变量组成的List数据结构,这样就可以记录不同场景间要走的路了。
[System.Serializable]
public class ScenePath
{
public string sceneName;
public Vector2Int fromGridCell;
public Vector2Int gotoGridCell;
}
//场景路径
[System.Serializable]
public class SceneRoute
{
public string fromSceneName;
public string gotoSceneName;
public List<ScenePath> scenePathList;
}
创建一个对应的Route的SO:
创建跨场景的路线信息,点为99999就是说明这一行数据不需要,只需要按照其设定的schedule移动即可。
另一方面,由于栈是逆向存储进去的,所以我们写在data的SO的时候也按照逆向的顺序。
然后在BuildPath中补充,非同场景下的移动的函数:
其实就是根据SO里存储的两条路径,然后依次生成两条路径并统一存储到MovementStep中
/// <summary>
/// 根据Schedule构建路径
/// </summary>
/// <param name="schedule"></param>
public void BuildPath(ScheduleDetails schedule)
{
movementSteps.Clear();
currentSchedule = schedule;
//targetScene = schedule.targetScene;
tragetGridPosition = (Vector3Int)schedule.targetGridPosition;
stopAnimationClip = schedule.clipAtStop;
if (schedule.targetScene == currentScene)
{
AStar.Instance.BuildPath(schedule.targetScene, (Vector2Int)currentGridPosition, schedule.targetGridPosition, movementSteps);
}
//跨场景移动
else if(schedule.targetScene != currentScene)
{
Debug.Log("跨场景移动");
SceneRoute sceneRoute = NPCManager.Instance.GetSceneRoute(currentScene, schedule.targetScene);
if (sceneRoute != null)
{
Debug.Log("sceneRoute != null");
for (int i = 0; i < sceneRoute.scenePathList.Count; i++)
{
Vector2Int fromPos, gotoPos;
ScenePath path = sceneRoute.scenePathList[i];
if (path.fromGridCell.x >= Settings.maxGridSize)
{
fromPos = (Vector2Int)currentGridPosition;
}
else
{
fromPos = path.fromGridCell;
}
if (path.gotoGridCell.x >= Settings.maxGridSize)
{
gotoPos = schedule.targetGridPosition;
}
else
{
gotoPos = path.gotoGridCell;
}
Debug.Log("build path:" + i+ " sceneName:"+path.sceneName+ " "+ fromPos + " " + gotoPos);
AStar.Instance.BuildPath(path.sceneName, fromPos, gotoPos, movementSteps);
}
Debug.Log("movementSteps.count" + movementSteps.Count);
}
}
if (movementSteps.Count > 1)
{
//更新每一步对应的时间戳
UpdateTimeOnPath();
}
}
在跨场景移动时我出了一个bug,很久才修复。
这个bug就是在跨场景移动时,移动到当前场景的结尾时,然后人物将会卡在那里,走路动画会继续播放,但是人物没法继续移动。
考虑bug产生的可能的原因:
- 人物当前判定点和目标点的距离没有小到足以判定是否到达位置了。(使用debug.log打印出位置看)
- 可能是地图碰撞体积设定的有问题,导致人物卡在一个地方走不过去,或者是目标导航点设置的有问题,导致人物是不可能走到设定的目标点的。
- 开始思考当初是怎么使用A*算法来实现导航的过程包含了每一步怎么移动,是通过使用什么方式进行移动的(考虑一些用于状态切换的变量是否有问题,进而导致了人物在某个状态没有切换过来,于是去确认每个状态变量的true 和false是否有问题,或者还有可能是在if和else的语句分支的条件判断中出了分歧,于是在if函数里面log,判断是否进入了这个分支。 除此之外,还通过log的数量判断,该函数的调用次数是否正确。
- If和else的分支没有出错后,用于状态切换的bool变量也没有赋值错误,那么考虑
(原理:建立一个移动movement的堆栈,调用movement函数,movement函数会在如果当前堆栈的数量大于0时则进行移动,然后根据堆栈里面存储的数据进行移动。
那么问题可能来源于,
于是将堆栈里面的一些信息打印出来,打印出来后发现,这个堆栈里面储存的变量个数不对。
1.堆栈生成的方法有误
2.或者是生成堆栈所需要的信息和变量不够(这种可以通过判断是否非空作为一种解决方式。)但是有些数据是需要在unity窗口里面,例如SO以及一些拖拽赋值的变量。
第一种可以先稍后考虑,因为如果第一种出错,之前的导航也可能会出错。
于是查看生成堆栈里数据需要什么变量,一看需要地图信息变量(长宽)。然后查找这个信息的来源并不是通过函数传递,而是通过SO存储起来的,那么就需要手动拖拽赋值信息。
另外,我们希望场景加载的时候,此时加载中,不希望npc此时移动,只需要场景加载时让时间停滞即可:
另外,前面有个地方需要改一下:
原来在获取瓦片信息时,上面那种写法没有考虑场景,而是直接用的当前场景
下面这种写法才考虑了场景。
最终即可完成,效果如下:
(npc切换场景后会有一个黑色的圈留下来,那个其实是影子,为了让npc场景切换后证明还在移动,把影子留下来了。)