Featured image of post 麦田物语开发日记(九)

麦田物语开发日记(九)

完成NPC寻路系统及行程预设

Astar算法实现NPC自动寻路

构建Astar数据结构

构建Astar算法数据结构的主要内容就是构造每个节点代价值Node.cs,然后为整个地图设置一个脚本GridNodes.cs,用于将整个地图的所有瓦片进行设置。

由于瓦片地图的坐标是以中心为(0, 0)节点的,因此获得瓦片地图的坐标需要从左下角原点位置获取。因此需要在MapData_SO文件中设置每个场景地图的坐标

  • Node.cs

  •  1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    public class Node : IComparable<Node>
    {
        private Vector2Int _gridPos;     // 当前节点的网格坐标
        private int _gCost = 0;          /* 当前节点距离起始点的距离 */
        private int _hCost = 0;          /* 当前节点距离重点的距离 */
        private bool _isObsticle = false;
        private Node _parentNode = null;
    
        public Node(Vector2Int pos)
        {
            _gridPos = pos;
            _parentNode = null;
    
        }
    
    
        private int FCost => _gCost + _hCost;
    
        // 当前节点的值更大,则返回1,小就返回-1,相等返回0
        public int CompareTo(Node other)
        {
            if (FCost == other.FCost)
            {
                return _hCost.CompareTo(other._hCost);
            }
    
            return FCost.CompareTo(other.FCost);
    
        }
    }
    
  • GridNodes.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class GridNodes
{
    /* 将瓦片地图的信息全部汇总 */
    private int _width;
    private int _height;
    private Node[,] _gridNode;       //每个坐标下的节点信息

    public GridNodes(int width, int height)
    {
        _width = width;
        _height = height;

        for (int x = 0; x < width; x++)
        {
            for (int y = 0; y < height; y++)
            {
                _gridNode[x, y] = new Node(new Vector2Int(x, y));
            }
        }
    }

    public Node GatGridNode(int x, int y)
    {
        if (x > _width || y > _height)
        {
            Debug.Log("超出网格信息");
            return null;
        }
        return _gridNode[x, y];
    }
    
    
}

根据每个地图信息生成节点数据

新建一个专门用于挂载到NPC的脚本AStar.cs,该脚本用于规划NPC的路径,因此该基本需要知道当前场景的所有基本信息

首先需要先获得当前场景的地图大小,因此要从GridMapManager.cs中获取地图的相关信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public bool GetGridDimensions(string sceneName, out Vector2Int gridDimension, out Vector2Int originPos)
{
    /* 对当前场景数据进行初始化 */
    gridDimension = Vector2Int.zero;
    originPos = Vector2Int.zero;
    
    foreach (var mapData in mapDataSos)
    {
        if (sceneName == mapData.sceneName)
        {
            gridDimension = new Vector2Int(mapData.wight, mapData.height);
            originPos = new Vector2Int(mapData.originX, mapData.originY);
            return true;
        }
    }
    
    return false;
    
}

然后需要处理AStar的所有网格信息,保存每个网格中能够找到所有的NPC的障碍物, 因此首先要初始化整个Astar算法的所有相关信息,然后去获得最短路径,

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
private GridNodes _gridNodes;
private Node _startNode;
private Node _endNode;

private Vector2Int _gridArea;
private Vector2Int _originPos;

private List<Node> _openNodeList;       /* 当前待遍历的节点列表*/
private HashSet<Node> _closedNodeList;   /* 已经被遍历过的节点列表 */

private bool _pathFound;        /* 已经找到最短路径的标志量 */

private int[,] _direction = new int[,]
    { { 1, 1 }, { 1, 0 }, { 1, -1 }, { -1, -1 }, { -1, 0 }, { -1, 1 }, { 0, 1 }, { 0, -1 } };

public void BuildPath(string sceneName, Vector2Int startPos, Vector2Int endPos)
{
    /* 初始化当前场景的信息 */
    _pathFound = false;
    if (GenerateGridNodes(sceneName, startPos, endPos))
    {
        /* 找出最短路径 */
        if (FindShortestPath())
        {
            /* 移动NPC */
        }
    }

}

/// <summary>
/// 构建网格信息,初始化两个列表
/// </summary>
/// <param name="sceneName">当前场景名称</param>
/// <param name="startPos">NPC的起始网格</param>
/// <param name="endPos">NPC的目标网格</param>
/// <returns></returns>
private bool GenerateGridNodes(string sceneName, Vector2Int startPos, Vector2Int endPos)
{
    /* 获得加载地图的场景信息 */
    if (GridMapManager.Instance
        .GetGridDimensions(sceneName, out Vector2Int gridDimension, out Vector2Int originPos))
    {
        _gridArea = gridDimension;
        _originPos = originPos;

        _openNodeList = new List<Node>();
        _closedNodeList = new HashSet<Node>();

        GridNodes curGridNodes = new GridNodes(gridDimension.x, gridDimension.y);

    }
    else
    {
        return false;
    }
    /* 将起始点和终点换算成节点矩阵坐标 */
    var convertStart = AStarConvert(startPos, AStarConvertType.CellToNodes);
    var convertEnd = AStarConvert(endPos, AStarConvertType.CellToNodes);
    _startNode = _gridNodes.GetGridNode(convertStart.x, convertStart.y);
    _endNode = _gridNodes.GetGridNode(convertEnd.x, convertEnd.y);

    /* 将GridNodes中每一个节点都初始化 */
    for (int x = 0; x < _gridArea.x; x++)
    {
        for (int y = 0; y < _gridArea.y; y++)
        {
            var curCell = AStarConvert(new Vector2Int(x, y), AStarConvertType.NodesToCell);
            Vector3Int tilePOS = new Vector3Int(curCell.x, curCell.y,  0);
            // 获得当前瓦片的障碍信息
            TileDetails curTile = GridMapManager.Instance.GetTileDetailsByMousePos(tilePOS);

            _gridNodes.GetGridNode(x, y).SetObstacle();
        }
    }
    return true;
}

private bool FindShortestPath()
{
    /* 使用最短路径算法 找出最短路径 */
    _openNodeList.Add(_startNode);

    while (!_pathFound && _openNodeList.Count > 0)
    {
        _openNodeList.Sort();       // 桶排序,升序排序
        var closeNode = _openNodeList[0];
        _openNodeList.RemoveAt(0);

        if (closeNode == _endNode)
        {
            _pathFound = true;
            break;
        }

        /* 将closeNode周围的点分别存入openNodeList中 */
        AddNeighborOfCloseNode(closeNode);
        _closedNodeList.Add(closeNode);

    }

    return _pathFound;
}

评估周围节点的代价

也就是获得节点的权重

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/// <summary>
/// 评估周围八个点
/// </summary>
/// <param name="closeNode"></param>
private void AddNeighborOfCloseNode(Node closeNode)
{
    Vector2Int curPos = closeNode.GetPos();
    for (int x = -1; x <= 1; x++)
    {
        for (int y = -1; y <= 1; y++)
        {
            if (x == 0 && y == 0) continue;
            var nextPosX = curPos.x + x;
            var nextPosY = curPos.y + y;
            var validNeighbourNode = GetValidNeighbourNode(nextPosX, nextPosY);

            if (validNeighbourNode != null && !_openNodeList.Contains(validNeighbourNode))
            {
                /* 计算代价 */
                validNeighbourNode.gCost = closeNode.gCost + GetDistance(closeNode, validNeighbourNode);
                validNeighbourNode.hCost = GetDistance(validNeighbourNode, _endNode);
                validNeighbourNode.parentNode = closeNode;
                _openNodeList.Add(validNeighbourNode);
            }

        }
    }
}

private Node GetValidNeighbourNode(int x, int y)
{
    if (x < 0 || y < 0 || x >= _gridArea.x || y >= _gridArea.y)
    {
        return null;
    }

    Node neighbourNode = _gridNodes.GetGridNode(x, y);
    if (neighbourNode.isObsticle || _closedNodeList.Contains(neighbourNode))
        return null;

    return neighbourNode;

}


private int GetDistance(Node nodeA, Node nodeB)
{
    int disX = Mathf.Abs(nodeA.GetPos().x - nodeB.GetPos().x);
    int disY = Mathf.Abs(nodeA.GetPos().y - nodeB.GetPos().y);

    // 如果X更大,那么说明要往x方向先走(disX - disY)步,才能斜方向走。
    if (disX > disY)
    {
        return (disX - disY) * 10 + disY * 14;
    }
    else
    {
        return (disY - disX) * 10 + disX * 14;
    }
    
    
}

在地图上绘制测试用的最短路径算法

由于已经得到最短路径,因此只需要为NPC设置每一步要走的位置。而获得位置的方法则是通过一个栈去不断迭代得到节点父节点,然后保存起来。

而NPC每个位置的节点需要用一个专门的数据结构保存下来

1
2
3
4
5
6
7
8
9
public class MovementStep
{
    public string sceneName;
    public int hour;
    public int minutes;
    public int seconds;

    public Vector2Int gridCoordinate;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// <summary>
/// 构建NPC移动的节点栈
/// </summary>
/// <param name="sceneName"></param>
/// <param name="npcMovementStep"></param>
private void UpdatePathOnMovementStepStack(string sceneName, Stack<MovementStep> npcMovementStep)
{
    Node nextNode = _endNode;

    while (nextNode != null)
    {
        
        npcMovementStep.Push(new MovementStep()
        {
            sceneName =  sceneName,
            gridCoordinate = AStarConvert(nextNode.GetPos(), AStarConvertType.NodesToCell)
        });
        
        nextNode = nextNode.parentNode;
    }
        
}

然后在统管整个Astar算法的方法中,执行上述方法,就能得到NPC的移动路径。

因此需要创建一个用于管理所有NPC移动的NPCMnager,然后挂载对应的Astar算法,同时挂载一个用于测试的脚本,该脚本需要通过瓦片地图绘制路径。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public class AStarTest : MonoBehaviour
{
    private AStar _aStar;
    [Header("测试点")]
    public Vector2Int startPos;
    public Vector2Int endPos;

    [Header("显示路径")]
    public bool displayNode;
    public bool displayPath;

    [Header("绘制工具")]
    public Tilemap displayMap;
    public TileBase tile;

    private Stack<MovementStep> _npcMovementStack;

    private void Awake()
    {
        _aStar = GetComponent<AStar>();
        _npcMovementStack = new Stack<MovementStep>();
    }

    private void Update()
    {
        ShowPathOnGridMap();
    }

    private void ShowPathOnGridMap()
    {
        if (displayMap == null || tile == null) return;
        
        if (displayNode)
        {
            displayMap.SetTile((Vector3Int)startPos, tile);
            displayMap.SetTile((Vector3Int)endPos, tile);
        }
        else
        {
            displayMap.SetTile((Vector3Int)startPos, null);
            displayMap.SetTile((Vector3Int)endPos, null);
        }

        if (displayPath)
        {
            _aStar.BuildPath(SceneManager.GetActiveScene().name, startPos, endPos, _npcMovementStack);
            foreach (var step in _npcMovementStack)
            {
                displayMap.SetTile((Vector3Int)step.gridCoordinate, tile);
            }
        }
        else
        {
            if (_npcMovementStack.Count > 0)
            {
                foreach (var step in _npcMovementStack)
                {
                    displayMap.SetTile((Vector3Int)step.gridCoordinate, null);
                }
            }
        }
    }
}

制作NPC基本信息,并实现场景切换

该部分主要由NpcMovement代码来控制Npc的移动和场景切换,该部分主要控制NPC移动相关的基本信息,以及其是否能出现在某个场景。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(Rigidbody2D))]
public class NpcMovement : MonoBehaviour
{
    /* 临时信息 */
    [SerializeField] private string currentScene;
    private string _targetScene;
    private Vector3Int _currentScenePos;
    private Vector3Int _targetScenePos;
    
    public string StartScene
    {
        set
        {
            currentScene = value;
        }
    }

    [Header("移动属性")] public float normalSpeed = 2f;
    public float minSpeed = 1f;
    public float maxSpeed = 3f;
    private Vector2 _dir;
    private bool _isMoving;
    
    /* 挂载组件 */
    private Rigidbody2D _rb;
    private Animator _animator;
    private SpriteRenderer _sprite;
    private BoxCollider2D _collider2D;

    private Stack<MovementStep> _movementSteps;

    private void Awake()
    {
        _rb = GetComponent<Rigidbody2D>();
        _animator = GetComponent<Animator>();
        _sprite = GetComponent<SpriteRenderer>();
        _collider2D = GetComponent<BoxCollider2D>();
        
    }

    private void OnEnable()
    {
        EventHandler.AfterSceneLoadEvent += OnAfterSceneLoadEvent;
    }

    private void OnDisable()
    {
        EventHandler.AfterSceneLoadEvent -= OnAfterSceneLoadEvent;
    }

    #region 注册事件

    private void OnAfterSceneLoadEvent()
    {
        CheckNpcVisible();
    }

    #endregion
    

    private void CheckNpcVisible()
    {
        if (currentScene == SceneManager.GetActiveScene().name)
            SetActiveInScene();
        else
            SetInActiveInScene();
    }

    #region 设置NPC显示情况

    private void SetActiveInScene()
    {
        _sprite.enabled = true;
        _collider2D.enabled = true;
        /* 显示阴影 */
        // transform.GetChild(0).gameObject.SetActive(true);
    }
    
    private void SetInActiveInScene()
    {
        _sprite.enabled = false;
        _collider2D.enabled = false;
        /* 显示阴影 */
        // transform.GetChild(0).gameObject.SetActive(false);
    }

    #endregion

Schedule 数据制作和路径生成

由于NPC是在网格中进行移动,因此需要先将NPC初始化到地图的网格中(初始化方法)。

然后设计一个SceduleDetails来在什么时间段,NPC应该出现在那个场景,做什么动作,因此在具体的控制代码中,需要用一个List来控制这些数据。

然后我们需要根据Scedule来生成NPC的行走路径,而路径需要每一步都有一个时间戳,因此在生成路径的时候需要使用TimeSpan来控制在路径过程中,每一步的时间。

使用TimeSpan的时候需要在TimeManager中为其设置值,因为其需要游戏系统设置的时间

  • NpcMoveMent.cs初始化Npc的位置信息
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
private void InitNpc()
{
    _targetScene = currentScene;
    
    /* 保证npc初始化的时候在网格中心点 */
    _currentScenePos = _gird.WorldToCell(transform.position);
    transform.position = new Vector3(_currentScenePos.x + Settings.gridCellSize / 2,
        _currentScenePos.y + Settings.gridCellSize / 2);
    _targetScenePos = _currentScenePos;
}

使用一个初始化标志量来确保,每次加载场景时,只有第一个能够调用这个方法

  • 设计NPC的行程数据
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[Serializable]
public class ScheduleDetails : IComparable<ScheduleDetails>
{
    public int  hour, minute, day;
    public int priority;
    public Season season;
    public string targetSceneName;
    public Vector2Int targetGridPosition;
    public AnimationClip clipAtStop;        /* 移动结束后停留动作 */
    public bool interactable;

    public ScheduleDetails(int hour, int minute, int day, int priority, Season season, string targetSceneName, Vector2Int targetGridPosition, AnimationClip clipAtStop, bool interactable)
    {
        this.hour = hour;
        this.minute = minute;
        this.day = day;
        this.priority = priority;
        this.season = season;
        this.targetSceneName = targetSceneName;
        this.targetGridPosition = targetGridPosition;
        this.clipAtStop = clipAtStop;
        this.interactable = interactable;
    }

    public int Time => hour * 100 + minute;

    public int CompareTo(ScheduleDetails other)
    {
        if (Time == other.Time)
        {
            return priority - other.priority;
        }
        else
        {
            return Time - other.Time;
        }

        return 0;
    }
}

并设计对应的DataSO文件,保证该数据能够被每一个NPC的Movement.cs文件调用

  • 为NPC生成路径生成算法
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public ScheduleDataList_SO scheduleData;
private SortedSet<ScheduleDetails> _scheduleDetailsSet;
private ScheduleDetails _curSchedule;

private void BuildPath(ScheduleDetails schedule)
{
    /* 为堆栈中的每一步生成时间 */
    _movementSteps.Clear();
    _curSchedule = schedule;

    if (schedule.targetSceneName == currentScene)
    {
        AStar.AStar.Instance.BuildPath(schedule.targetSceneName, (Vector2Int)_currentScenePos, schedule.targetGridPosition, _movementSteps);
    }
    else
    {
        
    }

    if (_movementSteps.Count >= 1)
    {
        // 更新每一步的时间戳
        UpdateTimeOnPath();
    }
}

在这一过程中需要为每一步都生成一个时间戳,通过这个时间戳能够在后续时间更新时,NPC到达指定位置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

private void UpdateTimeOnPath()
{
    MovementStep previousStep = null;
    TimeSpan currentGameTime = GameTime;
    foreach (MovementStep step in _movementSteps)
    {
        if (previousStep == null)
        {
            previousStep = step;
        }

        step.hour = currentGameTime.Hours;
        step.minutes = currentGameTime.Minutes;
        step.seconds = currentGameTime.Seconds;

        var nextStepDis =MoveInDiagonal(step, previousStep)
                ? Settings.gridCellSize
                : Settings.gridCellDiagonalSize;
        
        /* 计算下一步的时间 */
        TimeSpan gridMovementStepTime = new TimeSpan(0, 0, (int) (nextStepDis / normalSpeed / Settings.GameSecondHold));

        currentGameTime  = currentGameTime.Add(gridMovementStepTime);    // 下一步的时间

        previousStep = step;

    }
}

注意在使用TimeSpan的时候需要在TimeManager中进行初始化,然后通过的单例模式的方法获得该数值

1
2
3
4
5
// TimeManager.cs
public TimeSpan GameTime => new TimeSpan(_gameHours, _gameMinutes, _gameSeconds);

// NpcMoveMent.cs
private TimeSpan GameTime => TimeManager.Instance.GameTime;

实现NPC使用Astar算法移动

设计NPC的移动逻辑,然后放在FixedUpdate中进行调用,只要当前Npc的移动堆栈中存在数据,那么就让NPC进行移动。因此后续只需要为NPC创建路径,那么其到时候会随着帧数更新而移动。

在控制移动的过程中,需要先判断当前NPC是否正在移动,如果不在,则检测其是否到达了移动时间(到达时间则生成路径),如果有路径则让其开始移动,此时先检查NPC的移动是同场景移动还是不同场景的移动,如果不是同场景,则不予显示。

在正式移动的过程中先计算出其能否在规定时间内移动到目标位置,也就是利用距离和时间的比值,如果不行则直接瞬移过去,如果行则目标速度移动过去。

同时移动通过协程的方法,不断计算移动方向和移动的距离,使用rigidBody进行移动

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/// <summary>
/// 控制移动的方法
/// </summary>
private void Movement()
{
    if (!_npcMove)
    {
        if (_movementSteps.Count > 0)
        {
            MovementStep step = _movementSteps.Pop();

            currentScene = step.sceneName;

            CheckNpcVisible();

            _targetScenePos = (Vector3Int)step.gridCoordinate;
            /* 根据当前的时间和每一步的时间戳完成移动 */
            TimeSpan stepTime = new TimeSpan(step.hour, step.minutes, step.seconds);
            MoveToTargetGrid((Vector3Int)step.gridCoordinate, stepTime);
        }
    }
}

private void MoveToTargetGrid(Vector3Int stepPos, TimeSpan stepTime)
{
    /*  使用协程完成移动 */
    StartCoroutine(MoveRoutine(stepPos, stepTime));
}

/// <summary>
///  具体的移动函数
/// </summary>
/// <param name="stepPos"></param>
/// <param name="stepTime"></param>
/// <returns></returns>
private IEnumerator MoveRoutine(Vector3Int stepPos, TimeSpan stepTime)
{
    _npcMove = true;
    _nextStepWorldPos = GetWorldPosition(stepPos);
    /* 判断在有限时间内是否能通过行走到达指定位置 */
    if (stepTime > GameTime)
    {
        /* 计算移动位置的世界坐标,然后用rigidBody进行移动 */
        float timeMove = (float)(stepTime.TotalSeconds - GameTime.TotalSeconds);
        float distance =
            Vector3.Distance(_nextStepWorldPos, transform.position);
        float neededSpeed = Mathf.Min(minSpeed, (float)(distance / timeMove / Settings.GameTimeThreshold));
        if (neededSpeed <= maxSpeed)
        {
            /* 能够正常走到目标网格,那么判断像素距离是否达标 */
            while (Vector3.Distance(transform.position, _nextStepWorldPos) >
                   Settings.pixelSize)
            {
                _dir = (_nextStepWorldPos - transform.position).normalized;
                /* 计算位移 */
                Vector2 posOffset = new Vector2(neededSpeed * _dir.x * Time.fixedTime,
                    neededSpeed * _dir.y * Time.fixedTime);
                _rb.MovePosition(_rb.position + posOffset);
                yield return new WaitForFixedUpdate();
            }
        }
    }

    /*如果没有时间,或者正常达到,都是瞬移到目标位置*/
    _rb.position = _nextStepWorldPos;
    _currentScenePos = stepPos;
    _nextStepWorldPos = _currentScenePos;
    _npcMove = false;
}

private Vector3 GetWorldPosition(Vector3Int curGridPos)
{
    return _gird.WorldToCell(curGridPos) + new Vector3(Settings.gridCellSize / 2, Settings.gridCellSize / 2, 0);
}
  • 该部分的测试工作,则是新建一个schedual,然后构建对应的path即可。

制作NPC动画并完成按照日程表触发

在动画控制界面,由于我们默认的动画控制器是AnimatorController,而人物从空白动画切换到停止动画需要通过AnimatorOverrideController进行控制,因此需要使用上转的方式将当前运行的RuntimeAnimatorController改为AnimatorOverrideController,然后再修改动画片段。

  • 初始化过程
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* 动画计时器 */
private float _animationDelayTime;  
private bool _canPlayStopAnimation;
private AnimationClip _stopAnimationClip;
public AnimationClip blankAnimationClip;
private AnimatorOverrideController _animatorOverride;

private void Awake()
{
    // ...
    /* 修改动画的播放 */
    _animatorOverride = new AnimatorOverrideController(_animator.runtimeAnimatorController);
    _animator.runtimeAnimatorController = _animatorOverride;
}

// 移动结束的动画
private IEnumerator SetStopAnimation()
{
    /* 设置停止后的面向方位 */
    _animator.SetFloat("DirX", 0);
    _animator.SetFloat("DirY", -1);
    _animationDelayTime = Settings.AnimationDelayTime;
    if (_canPlayStopAnimation && _stopAnimationClip != null)
    {
        /* 修改_animatorOverride,此时其已经成为运行时动画 */
        _animatorOverride[blankAnimationClip] = _stopAnimationClip;
        _animator.SetBool("EventAnimation", true);
        yield return null;
        _animator.SetBool("EventAnimation", false);
    }
    else
    {
        _animatorOverride[_stopAnimationClip] = blankAnimationClip;
    }
}

同时需要在moveMent方法中增加当没有移动,并可以播放停止动画时候的判断

1
2
3
4
5
6
7
8
if (_movementSteps.Count > 0)
{
   //...
}
else if (!_isMoving && _canPlayStopAnimation)
{
    StartCoroutine(SetStopAnimation());
}
  • 利用时间推移的事件来触发NPC行程

先注册事件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
private void OnEnable()
{
	// ..
    EventHandler.GameMinuteEvent += OnGameMinuteEvent;
}


private void OnDisable()
{
    // ..
    EventHandler.GameMinuteEvent += OnGameMinuteEvent;
}

private void OnGameMinuteEvent(int min, int hour, int day, Season season)
{
    int curTime = 100 * hour + min;
    _curSeason = season;

    ScheduleDetails matchSchedule = null;       /* 与当前时间段匹配的行程 */

    foreach (ScheduleDetails scheduleDetails in _scheduleDetailsSet)
    {
        if (scheduleDetails.Time == curTime)
        {
            /* 如果当前行程是特定事件执行,但时间不对 */
            if (scheduleDetails.day != 0 && day == scheduleDetails.day)
                continue;
            if (scheduleDetails.season != season)
                continue;
            matchSchedule = scheduleDetails;
        }
        /* 排序是按照从小到大排序 */
        else if (scheduleDetails.Time > curTime)
        {
            break;
        }
    }

    if (matchSchedule != null)
    {
        BuildPath(matchSchedule);
    }
}

NPC跨场景移动

使用一个专门的数据结构来保存跨场景时候的路径,以及跨场景的要使用的所有路径。然后在NPCManager中管理路径相关的信息,用单例模式的形式来保证NPC在移动的过程中能够正确的移动到目标位置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[System.Serializable]
public class SceneRoute
{
    public string fromSceneName;
    public string gotoSceneName;
    public List<ScenePath> scenePathList;   /*跨场景的多条路径*/
}

[System.Serializable]
public class ScenePath
{
    public string sceneName;
    public Vector2Int fromGridCell;
    public Vector2Int gotoGridCell;
}

NPCManager中初始化路径相关的字典信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public SceneRouteDataList_SO sceneRouteDataListSo;
public Dictionary<string, SceneRoute> SceneRouteDict = new Dictionary<string, SceneRoute>();


protected override void Awake()
{
    base.Awake();
    InitSceneRouteDict();
}

private void InitSceneRouteDict()
{
    if (sceneRouteDataListSo.sceneRouteList.Count > 0)
    {
        foreach (SceneRoute sceneRoute in sceneRouteDataListSo.sceneRouteList)
        {
            string key = sceneRoute.fromSceneName + sceneRoute.gotoSceneName;
            if (SceneRouteDict.ContainsKey(key)) continue;
            SceneRouteDict.Add(key, sceneRoute);
        }
    }
}


public SceneRoute GetSceneRoute(string fromSceneName, string gotoSceneName)
{
    return SceneRouteDict[fromSceneName + gotoSceneName];
}

然后在NpcMovment中实现跨场景移动

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
else if (schedule.targetSceneName != currentScene)
{
    /* 获得跨场景的路径 */
    SceneRoute sceneRoute = NpcManager.Instance.GetSceneRoute(currentScene, schedule.targetSceneName);
    if (sceneRoute != null)
    {
        // 在设置数据的时候已经按照顺序保证场景移动,因此直接获取即可
        for (int i = 0; i < sceneRoute.scenePathList.Count; i++)
        {
            var path = sceneRoute.scenePathList[i];
            Vector2Int fromScenePos, gotoScenePos;
            

            if (path.fromGridCell.x > Settings.MaxCellLength ||
                path.fromGridCell.y > Settings.MaxCellLength)
            {
                fromScenePos = (Vector2Int)_currentScenePos;
            }
            else
            {
                fromScenePos = path.fromGridCell;
            }

            if (path.gotoGridCell.x > Settings.MaxCellLength ||
                path.gotoGridCell.y > Settings.MaxCellLength)
            {
                gotoScenePos = (Vector2Int)_targetScenePos;
            }
            else
            {
                gotoScenePos = path.gotoGridCell;
            }
            
            AStar.AStar.Instance.BuildPath(path.sceneName, fromScenePos, gotoScenePos, _movementSteps);
        }
    }
}

注意NPC在移动过程中,每一步要走的格子都是通过栈的方式压入的,我们在构建NPC走过的路径的时候,也要注意保证要走过的路径是从后往前的。也就是说加载的顺序需要从终点往起点加载

  • 修改Astar代码的错误

在之前Astar代码中获得当前瓦片的位置处获得瓦片信息是从当前激活场景中获得信息

1
string tileMapKey = mousePos.x + "x" + mousePos.y + "y" + SceneManager.GetActiveScene().name;

但是如果NPC跨越了场景,那么获得的瓦片信息还是当前场景的,就可以出现无法创建路径。因此需要对其进行修改

1
2
3
// 获得当前瓦片的障碍信息 
string key = tilePOS.x + "x" + tilePOS.y + "y" + sceneName;
TileDetails curTile = GridMapManager.Instance.GetTileDetails(key);
Built with Hugo
Theme Stack designed by Jimmy