游戏服务端寻路的思路与实现

作者: 陈码工 | 来源:发表于2018-06-21 18:45 被阅读214次

    本文以Python作为服务器, Unity作为客户端引擎, 主要讨论的是服务端实现寻路并由客户端表现架构下的程序设计思路.

    1. 整体思路

    首先, 我们讨论寻路算法的比较和选择: A-star一般来说有更好的性能, 因为它是从起点到终点路径上的点与起点之间的最短路径计算, 而Dijkstra算法则是起点到图结构中所有点的最短路径计算, 有一些浪费了.

    服务端寻路架构下, 服务端的作用在于定时根据游戏中玩家的位置变化, 进行新路线计算并把结果用point list的形式发送给客户端.
    客户端的职责, 则是要把point list实际转化为画面上怪物单位的连续移动.

    2. 讨论寻路算法

    寻路算法, 指的是找出Start点到End点之间的一条最短路径. 这条最短路径通过的是地图上的可通行区域, 即应当绕开堵塞区域(block area).

    我们主要考虑两个常见的最短路径算法:
    A-star(A* search algorithm)
    Dijkstra算法

    我们可以从时间开销方面去比较这俩算法:

    以常见的二叉堆(又名Priority Queue)实现为例, Dijkstra算法的时间开销为O(ElgV), E是边的数目, V是结点的数目, 算法最耗时的操作是从Q中提出V次的最小key值结点(extractMin), 和对所有E条边进行的decreaseKey操作.
    值得注意的是: 迪杰斯特拉算法能够找出从S(Source)点到所有其他图结构中结点的最短路径.

    Astar算法, 如果也是用二叉堆实现的话, 时间开销也是O(ElgV), 因为其最耗时操作也是Q中进行V次的extractMin操作, 以及对所有E条边进行的decreaseKey操作. 但是, 这里所说的都是算法的最坏情况, 也就是说找出来的最短路径需要遍历整个地图的最坏情况.

    由于Astar算法是一个贪心算法(F = G+H中的H数值是用曼哈顿距离估算的, 并不是一个精确可靠的值), 因此虽然Dijkstra和Astar在二叉堆实现情况下都是O(ElgV), 大多数情况下Astar算法的时间开销只会明显少于Dijkstra, 我个人粗略估计至少是少一个数量级, 也就是说不到1/10, 特别是地图越大这个比例会越小.

    用更直白的话来说, 迪杰斯特拉算法是找出S点到所有图中点的最短路径, A-star只找出S到E和S到路径上其他点的最短路径, 明显A-star要完成的任务更少. 由于游戏中我们大多数情况下只需要S到E点的最短路径, 因此A-star是更加省时节约开销的算法.

    3. A-star with heap的实现

    # encoding:utf-8
    
    import time
    from heapq import heappush, heappop
    """
    path finding module: A* path finding算法, 用heap实现版本
    
    quick guide:
    start = Node(None, 20, 20)
    end = Node(None, 50, 30)
    print find_path(start, end)  # return path list
    
    It reads a map in the beginning of this script. Currently layout_medium.txt is chosen.
    
    To download map(layout_medium.txt): 
    https://share.weiyun.com/5mtyHYi
    """
    
    
    _2dmap = []
    map_border = ()
    g_close_list = {}
    
    
    class Node:
        def __init__(self, father, x, y, end=None):
            if x < 0 or x >= map_border[0] or y < 0 or y >= map_border[1]:
                raise Exception("node position can't beyond the border!")
            self.father = father
            self.x = x
            self.y = y
            if father != None:
                G2father = calc_G(father, self)
                if not G2father:
                    raise Exception("father is not valid!")
                self.G = G2father + father.G
                self.H = calc_H(self, end)
                self.F = self.G + self.H  # 初始化的时候计算出F
            else:  # it has no father, thus it is the start point
                self.G = 0
                self.H = 0
                self.F = 0
    
        def reset_father(self, father, new_G):
            if father != None:
                self.G = new_G
                self.F = self.G + self.H
            self.father = father
    
    
    def calc_G(node1, node2):  # 该点
        dx = abs(node1.x - node2.x)
        dy = abs(node1.y - node2.y)
        if dx == 1 and dy == 0:
            return 10  # same row
        if dx == 0 and dy == 1:
            return 10  # same col
        if dx == 1 and dy == 1:
            return 14  # cross
        else:
            return 0
    
    
    def calc_H(cur, end):  # 估算该点到终点距离(忽略墙等阻挡物), 采用Manhattan distance
        return 10*(abs(end.x - cur.x) + abs(end.y - cur.y))
    
    
    def addAdjacentIntoOpen_new(heap, open_list, close_list, node, end):
        # 将该节点从开放列表移到关闭列表当中。
        open_list.pop((node.x, node.y))  # key 为(x, y)形式的坐标tuple
        close_list[(node.x, node.y)] = node
    
        _adjacent = []
        # 地图的layout的边界需要用0进行标记, 否则会进入except
        try:
            #_adjacent.append(Node(node, node.x - 1, node.y - 1, end))  # 这个时候就初始化了F值
            _adjacent.append(Node(node, node.x, node.y - 1, end))
            #_adjacent.append(Node(node, node.x + 1, node.y - 1, end))
            _adjacent.append(Node(node, node.x + 1, node.y, end))
            #_adjacent.append(Node(node, node.x + 1, node.y + 1, end))
            _adjacent.append(Node(node, node.x, node.y + 1, end))
            #_adjacent.append(Node(node, node.x - 1, node.y + 1, end))
            _adjacent.append(Node(node, node.x - 1, node.y, end))
        except Exception, e:
            print e
            print "***_adjacent append error!", (node.x, node.y)
            pass
    
        for a in _adjacent:
            if (a.x, a.y) == (end.x, end.y):
                new_G = calc_G(a, node) + node.G
                end.reset_father(node, new_G)
                # print "End point reached!"
                return True
            if (a.x, a.y) in close_list:  # 墙体等部分也在close_list中, 因此不会被认为是可以考虑的结点
                continue
            if (a.x, a.y) not in open_list:
                open_list[(a.x, a.y)] = a
                heappush(heap, (a.F, a))
            else:  # those nodes in open_list
                exist_node = open_list[(a.x, a.y)]
                new_G = calc_G(a, node) + node.G
                if new_G < exist_node.G:
                    exist_node.reset_father(node, new_G)
                    # how to update the value in heap? we can push this node into it, and try to clean the heap top later
                    # 因此, heap取出最小值的时候会检查是否已经在close_list中
                    heappush(heap, (exist_node.F, exist_node))
        return False
    
    def find_the_path_new(start, end):
        """need to use end node to extract result"""
        open_list = {}
        close_list = dict(g_close_list)
        if (start.x, start.y) in g_close_list.keys():
            print "start in block area"
            return end
        if (end.x, end.y) in g_close_list.keys():
            print "end in block area"
            return end
        open_list[(start.x, start.y)] = start
        heap = []
        the_node = start
        try:
            while not addAdjacentIntoOpen_new(heap, open_list, close_list, the_node, end):  # only return True when destination reached
                F, the_node = heappop(heap)
                while (the_node.x, the_node.y) in close_list.keys():  # the_node是已经走过了的点的话, 丢弃了再抽出一个新的the_node,
                    # 出现这个代码的原因是addAdjacentIntoOpen_new最后一个else语句中为了实现decreaseKey的操作, 直接塞入了新的结点, 而没有删除老的结点
                    # 这个操作一旦发生, 我们的Q中会出现重复的结点. 因此这里必须检查一下是否新取出的heapTop结点是已经在close_list中的走过的结点
                    F, the_node = heappop(heap)
        except Exception, e:
            print e
            pass
        return end
    
    def find_path(start, end):
        """return a path list of points from start to end"""
        serialized_list = []
        print 'Debug: start: ', (start.x, start.y), ' end: ', (end.x, end.y)
        end = find_the_path_new(start, end)
        if end.father:
            serialize_path(end.father, serialized_list)
            serialized_list.reverse()
            return serialized_list  # 反向, 从而变为起点到终点的路径
        else:
            return None
    
    # =======================================================================
    def print_map():
        print '    Y',
        for i in xrange(len(_2dmap)):
            print i,
        print
        print '  X'
        row = 0
        for l in _2dmap:
            print '%3d' % row, ' ',
            row = row + 1
            for i in l:
                print i,
            print
    
    def mark_path(node):
        if node.father == None: # start point
            return
        _2dmap[node.x][node.y] = '#'
        mark_path(node.father)
    
    def serialize_path(node, serialized_list):
        """extract result to a path list containing all the points orderly"""
        if node.father == None:
            return
        serialized_list.append((node.x, node.y))
        serialize_path(node.father, serialized_list)
    
    
    def read_map(filepath):
        global map_border
        f = open(filepath, 'r')
        line = f.readline()
        t, origin_pos = line.split("=")  # str type
        line = f.readline()
        t, height = line.split("=")
        line = f.readline()
        t, width = line.split("=")
        line = f.readline()
        t, accuracy = line.split("=")
    
        for line in f:
            # line = f.readline()
            line = line[1:-3].replace(',', '')
            l = list(line)
            _2dmap.append(l)
    
        map_border = (len(_2dmap), len(_2dmap[0]))
        row_index = 0
        for row in _2dmap:
            col_index = 0
            for n in row:
                if n == '0':  # 0 for block, not reachable
                    block_node = Node(None, col_index, row_index)  # 要非常注意x=col_index, y=row_index
                    g_close_list[(block_node.x, block_node.y)] = block_node
                col_index = col_index + 1
            row_index = row_index + 1
    
    
    def transform_path_list(path_list):
        if path_list:
            print "crude path:", path_list
            return [(p[0]-30.0, 0, 30.0-p[1]) for p in path_list]
        else:
            return []
    
    
    read_map('layout_medium.txt')  # read map in the beginning
    
    
    if __name__ == '__main__':
        print "original map:"
        print_map()
    
        start = Node(None, 8, 19) 
        end = Node(None, 52, 40)
        timePoint1 = time.time()
        res = find_path(start, end)  # core
        print res
        print 'time cost: ', time.time() - timePoint1
        # extra: mark and print path
        if res:
            mark_path(end.father)
            print_map()
    
    

    4. 地图信息的导出和使用

    首先, 是地图信息的导出和使用. 这部分的代码我是参考的https://blog.csdn.net/fansongy/article/details/51699058这篇文章所给出的一个脚本, 只是根据我的地图size去修改其的输入参数.

    按下Export按钮后的样子
    using UnityEngine;  
    using System.Collections;  
    using System.Text;  
    using UnityEditor;  
    
    // 将NavMesh转化为bitmap平面地图的类
    public class NavExport : MonoBehaviour  
    {  
        #region Public Attributes  
        public Vector3 leftUpStart = Vector3.zero;  
        public float accuracy = 1;  
        public int height = 30;  
        public int wide = 30;  
        #endregion  
     
        #region Unity Messages  
      
        void OnGUI()  
        {  
            if (GUILayout.Button("Export"))  
            {  
                exportPoint(leftUpStart, height, wide, accuracy);  
            }  
        }  
     
        #endregion  
     
        #region Public Methods  
      
        public void Exp()  
        {  
            exportPoint(leftUpStart, wide, height, accuracy);  
        }  
      
        public void exportPoint(Vector3 startPos, int x, int y, float accuracy)  
        {  
            StringBuilder str = new StringBuilder();  
            int[,] list = new int[x, y];  
            str.Append("startpos=").Append(startPos).Append("\r\n");  
            str.Append("height=").Append(y).Append("\r\nwide=").Append(x).Append("\r\naccuracy=").Append(accuracy).Append("\r\n");  
            for (int i = 0; i < y; ++i)  // row, 即y值
            {  
                str.Append("{");  
                for (int j = 0; j < x; ++j)  // col, x value
                {  
                    int res = list[j, i];  
                    UnityEngine.AI.NavMeshHit hit;  
                    for (int k = -10; k < 30; ++k)  
                    {   // startPos = (-30, 0, 30). x:(-30 ~ 30), y(30, -30)
                        if (UnityEngine.AI.NavMesh.SamplePosition(startPos + new Vector3(j * accuracy, k, -i * accuracy), out hit, 0.2f, UnityEngine.AI.NavMesh.AllAreas))  
                        {  
                            res = 1;  
                            break;  
                        }  
                    }  
                    Debug.DrawRay(startPos + new Vector3(j * accuracy, 0, -i * accuracy), Vector3.up, res == 1 ? Color.green : Color.red, 100f);  
                    str.Append(res).Append(",");  
                }  
                str.Append("},\n");  
            }  
            Debug.Log(str.ToString());  
            System.IO.File.WriteAllText(@"layout.txt", str.ToString(), Encoding.UTF8);
            Debug.Log("file written!"); 
        }  
        #endregion  
      
    }  
      
    [CustomEditor(typeof(NavExport))]  
    public class NavExportHelper : Editor  
    {  
        public override void OnInspectorGUI()  
        {  
            base.OnInspectorGUI();  
            if (GUILayout.Button("Export"))  
            {  
                var exp = target as NavExport;  
                exp.Exp();  
            }  
        }  
    }  
    
    导出时候的参数

    5. 服务端

    寻路是一项比较消耗CPU时间的任务, 因此我们要限制其的频率. 具体来说, 我们进行一次新寻路的条件判断, 主要抓住的是时间和位置. 如果时间间隔不够大(比如未到0.5sec), 就不寻路; 如果玩家没有移动, 那么怪物单位也没有必要再次进行寻路计算, 用之前的路线即可.

    if self.nav_timer >= NAVIGATION_INTERVAL and (abs(self.lastPlayerPosition[0]-player.position[0])> 0.5 or abs(self.lastPlayerPosition[2]-player.position[2])>0.5):
         self.lastPlayerPosition = player.position
         self.navigate(self.position, player.position)
    

    self.navigate函数的实现如下

        def navigate(self, start, end):
            start = HeapAstar.Node(None, int(round((start[0] + 30.0))), int(round((30.0-start[2]))))
            end = HeapAstar.Node(None, int(round((end[0] + 30.0))), int(round((30.0-end[2]))))
            self.path_list = HeapAstar.transform_path_list(HeapAstar.find_path(start, end))
    

    这里一个细节点, 是寻路算法对输入输出坐标的转换. 首先, NavExport.cs脚本所生成的layout.txt的x, y值(分别对应列数, 行数)都是大于0的正整数, 而我们Unity中的坐标往往是以(0, 0, 0)为中心的实数, 很多点的坐标还是负的, 比如(-1.6, 3, -10.52), 因此我们需要坐标的变换, 实现"实转整, 负转正".

    假设输入给NavExport.cs脚本的参数是
    startpos=(-30.0, 0.0, 30.0)
    height=60
    wide=60
    accuracy=1,

    那么, 对坐标转换处理的代码如下:

    # 输入坐标的转换
    start = HeapAstar.Node(None, int(round((start[0] + 30.0))), int(round((30.0-start[2]))))
    end = HeapAstar.Node(None, int(round((end[0] + 30.0))), int(round((30.0-end[2]))))
    
    # 输出坐标的转换
    def transform_path_list(path_list):
        if path_list:
            print "crude path:", path_list
            return [(p[0]-30.0, 0, 30.0-p[1]) for p in path_list]
        else:
            return []
    

    6. 客户端的实现

    客户端的收包与nav_path更新

    JArray navArray = (JArray)x.Value ["nav_path"];
    

    客户端的路径应用

    using UnityEngine;
    using System.Collections;
    using Newtonsoft.Json.Linq;
    
    public class EnemyMovement : MonoBehaviour
    {
    
        Transform player;  // remember it is of type Transform!!!
        public UnityEngine.AI.NavMeshAgent nav;
        public JArray nav_path;
        float moveSpeed = 2.0f;
        public float minDistance = 4f;
        public Vector3 lastTarget;
        EnemyHealth enemyHealth;
        PlayerHealth playerHealth;
        float timer = 0f;
        float lastTimePoint = 0f;
        Rigidbody enemyRigidbody;
    
    
        void Awake() {
            player = GameObject.FindGameObjectWithTag("Player").transform;  // init
            nav = GetComponent<UnityEngine.AI.NavMeshAgent>();
            nav.enabled = false;  // 默认不要打开, 否则会引发和服务端寻路的冲突
            enemyHealth = GetComponent<EnemyHealth> ();
            playerHealth = GameObject.FindGameObjectWithTag ("Player").GetComponent<PlayerHealth> ();
            lastTimePoint = Time.realtimeSinceStartup;
            enemyRigidbody = GetComponent<Rigidbody>();
        }
    
        void FixedUpdate() {
            timer = Time.realtimeSinceStartup - lastTimePoint;
            if (enemyHealth.currentHealth > 0 && !playerHealth.isDead) {  // if both are alive
    
                // 服务端和本地共同寻路
                // 远距离使用服务端寻路
                lastTimePoint = Time.realtimeSinceStartup;
                if (Vector3.Distance (transform.position, player.position) > minDistance && nav_path != null && nav_path.Count > 0) {
                    nav.enabled = false;
                    JToken elem = nav_path [0];
                    JArray posArray = (JArray)elem;
                    Vector3 target = new Vector3 ((float)posArray [0], (float)posArray [1], (float)posArray [2]);
    //              Debug.Log ("target, " + target.ToString("F4")  + "; position, " + transform.position.ToString("F4") );
    //              Debug.Log ("Distance, " + Vector3.Distance (transform.position, target));
    
                    if (Vector3.Distance (transform.position, target) <= 0.9f) { // 认为已经到达点了, 更新target
                        Debug.Log ("point touched, " + transform.position.ToString("F4") );
    //                  Debug.Log ("before, " + nav_path.Count);
                        nav_path.Remove (elem);
    //                  Debug.Log ("after, " + nav_path.Count);
                        if (nav_path.Count > 0) {
                            elem = nav_path [0];
                            target = new Vector3 ((float)posArray [0], (float)posArray [1], (float)posArray [2]);
                        }
                    } 
    
                    // 向着无论旧的还是新的target行进
                    Debug.DrawLine(transform.position, target, Color.yellow, 8f, false);
                    transform.LookAt (target);              //敌人面向追踪目标
                    transform.eulerAngles = new Vector3 (0.0f, transform.eulerAngles.y, 0.0f);  //设置敌人的Rotation属性,确保敌人只在y轴旋转
                    float deltaTime = Time.deltaTime;
                    Debug.Log("forward, " + transform.forward.ToString("F4") + " speed, " + moveSpeed.ToString("F4")  + " timer" + timer.ToString("F4") );
                    Vector3 movement = transform.forward * moveSpeed * timer;
                    timer = 0.0f;
                    Debug.Log ("movement, " + movement.ToString("F4") );
                        
                    Debug.Log("++++result:" + (transform.position+movement).ToString("F4"));
                    transform.position = movement + transform.position;
                    //enemyRigidbody.MovePosition(transform.position+movement); //敌人以moveSpeed的速度向点位靠近
                    Debug.Log("++++position:" + transform.position.ToString("F4"));
                } else {  // 近距离使用客户端寻路
                    nav.enabled = true;
                    nav.SetDestination (player.position);
                }
            } else {  // stop navigation if dead
                nav.enabled = false;
            }
        }
    
        void Update() {
        }
    }
    

    如此, 我们就可以实现服务端计算寻路路线, 客户端进行表现的架构设计了. 我们可以利用Debug.DrawLine函数来画出行走的路线(只在UnityEditor的Scene View中展现, 实际Build出来的游戏画面中是看不到的).

    // 输入参数依次是: line的起点, 终点, 颜色, 持续存在时间, false这个固定就这么写.
    Debug.DrawLine(transform.position, target, Color.yellow, 8f, false);
    
    最后客户端行走的效果

    参考资料

    https://blog.csdn.net/fansongy/article/details/51699058

    相关文章

      网友评论

        本文标题:游戏服务端寻路的思路与实现

        本文链接:https://www.haomeiwen.com/subject/bnfweftx.html