个人技术博客——基于AstarPathfindingProject的NPC自动寻路

222200105叶沈煜 2024-12-18 14:02:59
这个作业属于哪个课程FZU_SE_teacherW_4
这个作业要求在哪里软件工程实践总结&个人技术博客
这个作业的目标个人技术总结
其他参考文献《构建之法》

目录

  • 技术概述
  • 技术详述
  • 第一步:生成可行走的区域
  • 第二步:生成最短路径
  • 第三步:npc行走
  • 问题及解决方案
  • 问题一:路径和速度匹配问题
  • 问题二:地图生成时的卡顿问题
  • 总结
  • 参考文献

技术概述

技术的用途:AStarPathfinding是 Unity 中一个强大的寻路解决方案插件,在处理动态场景、复杂地形、多层地图等寻路需求时,该插件是一个不错的解决方案。
学习原因:我们项目中NPC自动寻路时涉及到相关的问题。
技术难点:如何让效果显示更为平滑、npc之间的避让。

技术详述

实现NPC的自动寻路分为三步,第一步是要生成可行走的区域;第二步,在生成的区域里找到当前地点到达目的地的最短路径(这一步采用A*算法实现);第三步,让NPC按照生成的路径行走。
接下来我将按照这三步,分别讲述对应的实现方式和涉及到的技术原理。由于在项目中只涉及到2D平面的寻路,所以就不去考虑3D游戏中的使用。

第一步:生成可行走的区域

1. 添加脚本
在AStarPathfinding中的核心脚本是“astarpath.cs”,在场景中必须存在一个对象,其身上添加了该脚本。通常的做法是新建一个空对象添加该脚本。挂载后点击该标签显示如下的属性设置界面,调整各个属性实现想要的效果。
点击该检查器下方的“scan”按钮,会根据设置生成相应的地图,每次修改属性之后,都需要重新进行扫描。可以使用快捷键Cmd+Alt+S (mac) 或 Ctrl+Alt+S (windows)实现扫描。
2. 设置参数
接下来介绍一下图表中的一些常用的参数。
Shape表示要生成的地图的类型,在官网中提供了一个流程图来评估你的游戏适合的地图类型,在我的项目中我使用的是默认的网格地图

在这里插入图片描述

Width和Depth,顾名思义,这两个参数表示要生成地图的宽度和长度。
NodeSize变量确定网格中正方形/节点的大小。默认设置为 1,因此节点将间隔 1 个单位。
位置参数,与UI中的Rect Transform类似,这个坐标可以设置锚点为图的中心,或者其他四个角。当在一个平台地图中,建议时将锚点设置成底部的Bottom Left,这样才能与地面对准,不过需要注意的是y轴坐标往往设置成-0.1,而不是地面的高度0,从官网中的文档我们可以发现,这样做是为了避免浮点错误,地面位于 Y=0 处,如果图形的位置也为 Y=0,当向它投射光线时就有可能出现错误。不过我们的项目中并非这样的平台游戏,所以这一点并不会有影响。
Connection,这个参数用来指定生成的路径是八个方向的还是四个方向的。由于我们的npc只有四个方向的行走图,所以,我们设置为Four。
Use 2D Physics,如果是2D游戏就勾选,否则就不勾。
Obstacle Layer Mask,用来设置要屏蔽的图层,如果设置了相应的图层,那么在扫描时,这个图层物体所在的区域就会被跳过。

在这里插入图片描述

本项目中的设置如下

在这里插入图片描述

设置完成之后,扫描,可以看到生成的地图(蓝色部分),并且被屏蔽的图层所在区域被跳过

在这里插入图片描述

3. 运行时的扫描
在游戏中也许会有地形发生变化,或游戏场景中的物品位置发生变化的时候,这时候,可以在场景变化后调用scan,重新对场景进行扫描。

// 扫描所有的地图
AstarPath.active.Scan();

// 仅扫描第一个网格地图
var graphToScan = AstarPath.active.data.gridGraph;
AstarPath.active.Scan(graphToScan);

// 根据需要,仅扫描对应编号的几个地图
var graphsToScan = new [] { AstarPath.active.data.graphs[0], 
AstarPath.active.data.graphs[2] };
AstarPath.active.Scan(graphsToScan);

不过,需要注意的是,这个扫描的消耗比较大,如果是在玩家正常游玩时需要重新扫描地图,建议使用异步的方式(对应的代码如下),否则会造成画面的严重卡顿,如果频繁的调用,那么游戏体验会大大下降。

IEnumerator Start () {
    //ScanAsync()返回扫描的进度
    foreach (Progress progress in AstarPath.active.ScanAsync()) {
        Debug.Log("扫描中... " + progress.ToString());
        yield return null;
    }
}

第二步:生成最短路径

挂载Seeker脚本
在生成地图之后,我们要做的就是在这片可行走的区域中找出一条可以让npc从当前位置到达指定位置的最短路径。要做到这一点,就需要使用到Seeker脚本。

在这里插入图片描述


将该脚本组件添加到npc身上,该脚本是一个辅助脚本,用于从其他脚本调用请求路径,并且,从官方文档可以得知,它还可以处理路径修饰符,例如,平滑路径或使用光线投射简化路径。不过由于我们的路径本身就是按着格子行走的,也就是说,是由一条条横线和直线构成的,所以并不需要使用到平滑路径。
生成路径
现在,我们要做的就是生成一条路径,我们需要使用Seeker脚本里的StartPath方法,这个方法的声明如下

Path StartPath (Vector3 start, Vector3 end, OnPathDelegate callback = null)

start代表路径的起点
end代表路径的终点
callback是一个委托,代表回调函数,可以在生成后调用
生成路径的代码如下

 public void Start () {
        // 获取本对象上的Seeker脚本
        Seeker seeker = GetComponent<Seeker>();

        //请求生成一条到达目标位置的新路径,结果返回到回调函数中
        //该路径生成请求是异步执行的,所以不会影响游戏运行。
        seeker.StartPath(transform.position, targetPosition.position, OnPathComplete);
    }

在我们项目中的代码如下

/// <summary>
/// 生成路径算法
/// </summary>
void GeneratePath(Vector3 target)
{
    currentIndex = 0;//将路径点索引设置成0
    //异步操作,等待执行完成后再让npc重新行动
    //参数:起点,终点,回调函数
    seeker.StartPath(transform.position, target, Path =>
    {
        pathPointList = Path.vectorPath;//获取生成的路径点数组
        arrive = false;//将npc的状态设置成未抵达目的地
    });
}

从我们项目的代码可以更加直观的看出,生成的结果作为参数传入到回调函数中,并可以在该函数对该路径数组进行操作(如存储该路径点)。

到这一步,如果我们运行游戏,并为npc设置一个目标点,那么就能看到地图上生成了相应的路径。
这里在首尾的位置可能存在斜线的情况,不过实际的效果上并不会有很大影响。

在这里插入图片描述

到这一步我们就已经为npc的行走做好了准备,接下去就要让npc按照该路径行走。

第三步:npc行走

单个npc实现过程
在第二步中,我们已经得到了该路径的所有路径点,我们要想让npc沿着这条路走到终点,就只需要让npc把这些路径点一个个走完即可。那么现在要做的就是从路径数组中取最开始那个点,让npc走到该点,之后再取下一个点,让npc再走到下一个点,以此类推。
以下是官网提供的一个模板脚本,并且官方建议采用该写法实现。即便想要自行编写实现,也可以了解一下官网的写法,以便更加理解该算法的原理和实现的细节。

        public void Update () {
        if (path == null) {
            // We have no path to follow yet, so don't do anything
            return;
        }

        // Check in a loop if we are close enough to the current waypoint to switch to the next one.
        // We do this in a loop because many waypoints might be close to each other and we may reach
        // several of them in the same frame.
        reachedEndOfPath = false;
        // The distance to the next waypoint in the path
        float distanceToWaypoint;
        while (true) {
            // If you want maximum performance you can check the squared distance instead to get rid of a
            // square root calculation. But that is outside the scope of this tutorial.
            distanceToWaypoint = Vector3.Distance(transform.position, path.vectorPath[currentWaypoint]);
            if (distanceToWaypoint < nextWaypointDistance) {
                // Check if there is another waypoint or if we have reached the end of the path
                if (currentWaypoint + 1 < path.vectorPath.Count) {
                    currentWaypoint++;
                } else {
                    // Set a status variable to indicate that the agent has reached the end of the path.
                    // You can use this to trigger some special code if your game requires that.
                    reachedEndOfPath = true;
                    break;
                }
            } else {
                break;
            }
        }

        // Slow down smoothly upon approaching the end of the path
        // This value will smoothly go from 1 to 0 as the agent approaches the last waypoint in the path.
        var speedFactor = reachedEndOfPath ? Mathf.Sqrt(distanceToWaypoint/nextWaypointDistance) : 1f;

        // Direction to the next waypoint
        // Normalize it so that it has a length of 1 world unit
        Vector3 dir = (path.vectorPath[currentWaypoint] - transform.position).normalized;
        // Multiply the direction by our desired speed to get a velocity
        Vector3 velocity = dir * speed * speedFactor;

        // Move the agent using the CharacterController component
        // Note that SimpleMove takes a velocity in meters/second, so we should not multiply by Time.deltaTime
        //controller.SimpleMove(velocity);

        // If you are writing a 2D game you should remove the CharacterController code above and instead move the transform directly by uncommenting the next line
        transform.position += velocity * Time.deltaTime;
    }

我们项目中的移动脚本实现思路与官方文档类似,不过在一些细节上有些不同,在下文的问题部分,我将详细阐述为什么要做这样的更改。
下面先给出我们项目的实现代码

private void FixedUpdate()
{
    if (!npcMove && arrive == false)
    {
        Move();
    }

}

private void Move()
{
    if (pathPointList != null)
    {
        if (currentIndex < pathPointList.Count)
        {
            MoveStep(pathPointList[currentIndex]);
            currentIndex++;
        }
        else
        {
            animator.SetFloat("lastVertical", 1);
            animator.SetFloat("lastHorizontal", 0);
            arrive = true;
        }
    }
    //rigidbody2d.velocity = motionVector * speed;
}

private void MoveStep(Vector3 positon)
{
    StartCoroutine(MoveRoutime(positon));
}

private IEnumerator MoveRoutime(Vector3 position)
{
    animator.SetBool("moving", true);
    npcMove= true;
    while (Vector3.Distance(transform.position, position) > (1f / 16))
    {
        motionVector = (position - transform.position).normalized;
        animator.SetFloat("horizontal", motionVector.x);
        animator.SetFloat("vertical", motionVector.y);
        animator.SetFloat("lastHorizontal", motionVector.x);
        animator.SetFloat("lastVertical", motionVector.y);
        Vector2 offset = new Vector2(motionVector.x * speed * Time.fixedDeltaTime, motionVector.y * speed * Time.fixedDeltaTime);
        rigidbody2d.MovePosition(rigidbody2d.position + offset);
        //transform.position += new Vector3(offset.x, offset.y, 0);
        yield return new WaitForFixedUpdate();
    }
    rigidbody2d.position = position;
    animator.SetBool("moving", false);
    npcMove = false;
}

扩展到多npc(实现npc相互避让)
在上文中我们已经实现了单个npc的自动寻路,实际运行项目后,效果良好,npc能够很好的按照既定的路线进行移动。现在,我们考虑实现多个npc的自动寻路。
最简单的处理就是直接生成多个这样的npc,让他们分别按照自己的路径行走,不去感知其他npc的存在。
但是实际运行后会发现npc的路径可能会有重叠或者交叉的部分,所以,npc之间可能会相互挡道,导致大家都僵持在一个位置。要解决这个问题,就要对上面的代码进行修改。
首先,我们需要为npc添加DynamicGridObstacle组件,这个组件专门为移动障碍物设计。当npc挂上这个组件后,我们会发现,地图上的蓝色区域在实时更新

在这里插入图片描述

这样,我们就能实时更新每个npc可行走的区域。现在要做的,就是依照新的地图,刷新npc的行走路径。
不过过多的刷新会导致性能下降。所以我们不能在每一帧中都调用生成路径的函数,我的做法是间隔一定时间,刷新一次,这样就能保证,npc能够最终一定能够走到目的地。
不过,如果每个npc的时间间隔都是一样的,就会导致,每个npc都同时刷新路径,那么新生成的路径又可能会存在重叠,所以我的解决方案是让刷新时间是取一定区间内的随机值,这样就不会每次都同时刷新,确保不会出现卡死的现象。

不过,除了这样的方式,还有一个比较简单的方案,不用重新刷新npc的路径,那就是将npc单独设置一个图层,并将其碰撞体检测设置成不会检测到相同图层的其他对象。这样相互之间即使存在重叠路径,也不会相互干扰。

问题及解决方案

问题一:路径和速度匹配问题

问题概述
上文说到采用官方给的模板写法可以实现基本的寻路,不过这样的写法在实际测试中会发现,在靠近目的地的时候,npc的移动速度会变慢。这样能很好的控制npc靠近目标点。
而如果将脚本中的速度因子删除,当npc的速度足够快时,npc在每一帧的移动较快,导致npc可能直接错过该路径点,甚至是错过下一个路径点,就有可能走过头。但是我们的程序又是严格按照路径点顺序,一个一个遍历,因此就会出现,以第 i 路径点为目标时,已经走过了 i + 1 这个路径点,但是下一次又以 i + 1 为目标,那么npc就会往回走一段,再继续往前走,一直反反复复。
由于我们的游戏中希望npc是一个一个行走的,所以需要控制npc每一步都走到格子中心,这样简单采用官网的方法并不能很好的实现。
解决方案
首先要确保npc的速度在一个合理的范围,不宜太快,否则很容易出现这个问题,其次,我们保证在还未到达目标点之前,不会取下一目标点,这一点采用协程控制。进入协程的条件是npcMove=false。

private IEnumerator MoveRoutime(Vector3 position)
{
    animator.SetBool("moving", true);
    npcMove= true;
    while (Vector3.Distance(transform.position, position) > (1f / 16))
    {
        motionVector = (position - transform.position).normalized;
        animator.SetFloat("horizontal", motionVector.x);
        animator.SetFloat("vertical", motionVector.y);
        animator.SetFloat("lastHorizontal", motionVector.x);
        animator.SetFloat("lastVertical", motionVector.y);
        Vector2 offset = new Vector2(motionVector.x * speed * Time.fixedDeltaTime, motionVector.y * speed * Time.fixedDeltaTime);
        rigidbody2d.MovePosition(rigidbody2d.position + offset);
        yield return new WaitForFixedUpdate();
    }
    rigidbody2d.position = position;
    animator.SetBool("moving", false);
    npcMove = false;
}

其次,将npc的移动脚本写在FixedUpdate()中,这样能更好的模拟npc的物理移动。
由于我们的游戏中一个格子的大小是16像素,所以我们判断是否到达格子中心的方式是判断和格子中心的距离是否大于1个像素点:1f/16。
通过这样的方式,即使不用官网中的调整速度因子,也能很好的解决该问题。

问题二:地图生成时的卡顿问题

问题概述
由于生成地图时会导致性能下降,游戏变得卡顿,而在游戏过程中,又必须要刷新地图。
解决方案
在运行时,采用异步的方式实现地图的生成,就不会对性能造成很大的影响。
调用的异步方式如下,这在上文追踪也有提及。

IEnumerator Start () {
    //ScanAsync()返回扫描的进度
    foreach (Progress progress in AstarPath.active.ScanAsync()) {
        Debug.Log("扫描中... " + progress.ToString());
        yield return null;
    }
}

总结

AStarPathFinding整体而言是一个不错的寻路方案,其中还有很多的功能我尚未完全熟悉,如一些修饰符的使用,以及回合制游戏中常用的避让方案。在之后做回合制游戏时,可以详细的学习一下这个知识。
此外,插件的付费版本提供了更为丰富的功能,如ROV Controller等等用于局部避障的方案,这些能够很好的解决上文所说的npc之间的避让问题(我自己写的办法相比之下比较粗糙)。
使用下来,我觉得,这个插件的用法核心在于地图的扫描路径点的遍历。地图扫描需要设置好,哪些区域是可行走的哪些不可行走。
而对于路径点的遍历,既可以用官方提供的现成组件实现,也可以用官方提供的模板脚本进行修改实现。核心在于理解路径的存储方式,遍历路径点,进行实现npc移动的效果。在实现过程中需要注意避免上文所说的走过头的问题。

参考文献

AStarPathfinding官网
Unity用户手册

...全文
194 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

239

社区成员

发帖
与我相关
我的任务
社区管理员
  • FZU_SE_teacherW
  • 助教赖晋松
  • D's Honey
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧