游戲服務端尋路的思路與實現

本文以Python作為服務器, Unity作為客戶端引擎, 主要討論的是服務端實現尋路并由客戶端表現架構下的程序設計思路.

1. 整體思路

首先, 我們討論尋路算法的比較和選擇: A-star一般來說有更好的性能, 因為它是從起點到終點路徑上的點與起點之間的最短路徑計算, 而Dijkstra算法則是起點到圖結構中所有點的最短路徑計算, 有一些浪費了.

服務端尋路架構下, 服務端的作用在于定時根據游戲中玩家的位置變化, 進行新路線計算并把結果用point list的形式發(fā)送給客戶端.
客戶端的職責, 則是要把point list實際轉化為畫面上怪物單位的連續(xù)移動.

2. 討論尋路算法

尋路算法, 指的是找出Start點到End點之間的一條最短路徑. 這條最短路徑通過的是地圖上的可通行區(qū)域, 即應當繞開堵塞區(qū)域(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是更加省時節(jié)約開銷的算法.

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):
    # 將該節(jié)點從開放列表移到關閉列表當中。
    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的操作, 直接塞入了新的結點, 而沒有刪除老的結點
                # 這個操作一旦發(fā)生, 我們的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  # 反向, 從而變?yōu)槠瘘c到終點的路徑
    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))

這里一個細節(jié)點, 是尋路算法對輸入輸出坐標的轉換. 首先, 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;  // 默認不要打開, 否則會引發(fā)和服務端尋路的沖突
        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的起點, 終點, 顏色, 持續(xù)存在時間, false這個固定就這么寫.
Debug.DrawLine(transform.position, target, Color.yellow, 8f, false);
最后客戶端行走的效果

參考資料

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市垦江,隨后出現的幾起案子嗓化,更是在濱河造成了極大的恐慌棠涮,老刑警劉巖,帶你破解...
    沈念sama閱讀 210,914評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件刺覆,死亡現場離奇詭異严肪,居然都是意外死亡,警方通過查閱死者的電腦和手機谦屑,發(fā)現死者居然都...
    沈念sama閱讀 89,935評論 2 383
  • 文/潘曉璐 我一進店門驳糯,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人氢橙,你說我怎么就攤上這事酝枢。” “怎么了悍手?”我有些...
    開封第一講書人閱讀 156,531評論 0 345
  • 文/不壞的土叔 我叫張陵帘睦,是天一觀的道長。 經常有香客問我坦康,道長竣付,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,309評論 1 282
  • 正文 為了忘掉前任滞欠,我火速辦了婚禮古胆,結果婚禮上,老公的妹妹穿的比我還像新娘筛璧。我一直安慰自己逸绎,他們只是感情好,可當我...
    茶點故事閱讀 65,381評論 5 384
  • 文/花漫 我一把揭開白布夭谤。 她就那樣靜靜地躺著桶良,像睡著了一般。 火紅的嫁衣襯著肌膚如雪沮翔。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,730評論 1 289
  • 那天曲秉,我揣著相機與錄音采蚀,去河邊找鬼。 笑死承二,一個胖子當著我的面吹牛榆鼠,可吹牛的內容都是我干的。 我是一名探鬼主播亥鸠,決...
    沈念sama閱讀 38,882評論 3 404
  • 文/蒼蘭香墨 我猛地睜開眼妆够,長吁一口氣:“原來是場噩夢啊……” “哼识啦!你這毒婦竟也來了?” 一聲冷哼從身側響起神妹,我...
    開封第一講書人閱讀 37,643評論 0 266
  • 序言:老撾萬榮一對情侶失蹤颓哮,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后鸵荠,有當地人在樹林里發(fā)現了一具尸體冕茅,經...
    沈念sama閱讀 44,095評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,448評論 2 325
  • 正文 我和宋清朗相戀三年蛹找,在試婚紗的時候發(fā)現自己被綠了姨伤。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,566評論 1 339
  • 序言:一個原本活蹦亂跳的男人離奇死亡庸疾,死狀恐怖乍楚,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情届慈,我是刑警寧澤徒溪,帶...
    沈念sama閱讀 34,253評論 4 328
  • 正文 年R本政府宣布,位于F島的核電站拧篮,受9級特大地震影響词渤,放射性物質發(fā)生泄漏。R本人自食惡果不足惜串绩,卻給世界環(huán)境...
    茶點故事閱讀 39,829評論 3 312
  • 文/蒙蒙 一缺虐、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧礁凡,春花似錦高氮、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,715評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至窟蓝,卻和暖如春罪裹,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背运挫。 一陣腳步聲響...
    開封第一講書人閱讀 31,945評論 1 264
  • 我被黑心中介騙來泰國打工状共, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人谁帕。 一個月前我還...
    沈念sama閱讀 46,248評論 2 360
  • 正文 我出身青樓峡继,卻偏偏與公主長得像,于是被迫代替她去往敵國和親匈挖。 傳聞我的和親對象是個殘疾皇子碾牌,可洞房花燭夜當晚...
    茶點故事閱讀 43,440評論 2 348

推薦閱讀更多精彩內容