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

本文以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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容