與A-Star不同的像素級尋路算法上

avtl8653 8年前發布 | 24K 次閱讀 尋路算法 算法 Dijkstra算法

來自: http://www.alloyteam.com/2016/03/and-a-star-on-different-pixel-pathfinding-algorithm/

前言:

尋路是游戲中非常重要的一項功能,這項功能將直接體現出AI的智商如何。那說起尋路的算法,就不得不提標題上面的A star算法了。A Star(又稱A*),是結合了Dijkstra算法和貪心算法優點的算法,對此不了解的同學可以去搜索一下,這里不具體介紹實現,而是簡單的說一下原理,為后面我們的主角鋪墊。

A Star的核心在于將游戲背景 分為一個又一個格子 ,每個格子有自己的靠譜值,然后通過遍歷起點的格子去找到周圍靠譜的格子,接著繼續遍歷周圍……最終找到終點。好了,A Star的介紹就到這里了,因為它不是文章的主角。

文章篇幅較長所以分為上下文,下文地址:

上下文各有一種實現方式,區別看了就知道,此外上文包含了一些研究尋路的思考。

開頭介紹A Star我們提及了格子,這是A Star最核心的一個地方,不過并不是所有游戲都引入了格子的概念,還有很多的游戲是像素級別的。雖然說像素也是一個個的格子,如果用A Star針對像素來尋路,試想如果一個1366*768分辨率的屏幕,那格子的總數將會有1049088個,這明顯是很不合適的。

那么接下來就有請我們的文章主角出場了,算法沒有名字,因為百分百原創,可能還有一些自己沒有發現的坑點,希望和大家一起討論研究~

1、尋路的本質是什么

在現實生活中尋路就是從起點到達目的地,在游戲中也是如此,區別在于我們人在現實中會自己避開各個障礙物,而計算機不會,所以尋路本質就是 我們幫助計算機避開一個又一個障礙物

如果我們對計算機下達一個從紅點到藍點的命令,如圖

第二幅圖中我們必須要幫助計算機繞開多邊形

2、該以怎么樣的方式繞開

下圖中紅線和藍線都是繞開的表現,但所有人都不會選擇藍色的方式,因為它遠

遠的路徑有很多很多,那么什么是最近的呢,我們知道: 兩點之間,線段最短

但我們用最短路徑的話一定與障礙物多邊形相交,這時候我們換一種思維,不考慮繞過,而考慮什么時候不會相交,如圖

看!只要將起點終點平移了就沒有相交了!

先別慌著噴我……看下圖

這里A點我稱之為必過點,就是以最短繞開路徑一定會經過的點,①

我們剛剛通過平移移動起點和終點的絕對位置,不改變它們相對位置,掃過了障礙物區域,發現了一個必過點

整個尋路的思想就是如此!

接下來說具體在程序中是如何實現的

a、先將起點終點連接

b、針對連線的垂直平分線方向平移

c、直到發現連線沒有與障礙物相交即可

d、去障礙物上與那個沒有相交的線最近點(也就是必過點),分別與起點和終點相連

整個流程就是如此,下面說實現過程中的一些難點和坑點

1、如何找到必過點

因為我們所說的連線與障礙物相交,本質上是與障礙物中的某一條線相交了

圖中藍線和紅線相交所以說與障礙物相交了

我們找必過點的方式就是首先找到連線最后相交的障礙物的兩點,然后針對這兩點分別求到起點和終點的距離

就是圖中比較a1+a2和b1+b2的大小,距離小的就是必過點

2、第一次找到的必過點無法直接和終點或起點相連

這個就是用核心思想遞歸的再去找下一個必過點

不過代碼里面我是用循環做的

給出一張開啟debug的演示圖

那么多黑點黑線是平移的軌跡,然后黃點是第一個必過點,此時發現黃點無法與藍色點相連,那么continue,以黃點為起點和藍點為終點,找出第二個必過點粉色的點

3、平移連線穿過必過點,大坑

注意小標題中的 穿 字,先用一個例子表示出來

開啟debug我們走一遍看看~

注:我這里設定了只找一個必過點,所以出現了穿越的情況

但問題是怎么必過點(綠點)會出現在這兒!!

因為那個紅框, 平行線(紅框內的那個)過了障礙物沒問題,但它是穿越過去的! 不是按我們想的橫掃過去的!

那該如何解決,這時候就需要我們矯正起點和終點了

只需要把起點和終點都保證在障礙物的AABB外就可以了,AABB就是將一個多邊形看成長方形,它的x,y,width和height

這個在AABB外也不是隨便取得,必須按照連線的方向去延長,讓我們看看效果

如圖所示

在線演示地址:http://westanhui.github.io/navigate/index.html

注意:點擊畫障礙物,通過左鍵單擊畫多邊形最后右鍵自動閉合圖像

下面貼核心代碼和解釋(完整代碼可查看頁面源代碼)

function determinant(a, b, c, d) {
    return (a * d - b * c);
}
 
function isLineCross(a1, a2, b1, b2) {
    if(globalIndex++ > 1000) {
        throw 'to much times';
    }
    var delta = determinant(a2.x - a1.x, b1.x - b2.x, a2.y - a1.y, b1.y - b2.y);
    if(delta <= (1e-6) && delta >= -(1e-6)) {
        return false;
    }
    var namenda = determinant(b1.x - a1.x, b1.x - b2.x, b1.y - a1.y, b1.y - b2.y) / delta;
    if(namenda > 1 || namenda < 0) {
        return false;
    }
 
    var miu = determinant(a2.x - a1.x, b1.x - a1.x, a2.y - a1.y, b1.y - a1.y) / delta;
    if(miu > 1 || miu < 0) {
        return false;
    }
 
    return true;
}

這個是工具方法,判斷兩條線是否相交的,用的是矩陣的方法

// 首先判斷起點終點是不是在障礙物內
var outPoint = getPointOutside(obstacles);
 
if(isInObstacles(outPoint, start, obstacles)) {
    alert('fuck');
    return ;
}
if(isInObstacles(outPoint, end, obstacles)) {
    alert('fuck');
    return ;
}
function isInObstacles(start, end, obstacles) {
    var index = 0;
    var result;
    for(var i = 0; i < obstacles.length - 1; i++) {
        result = isLineCross(start, end, obstacles[i], obstacles[i + 1]);
        if(result) {
            index++;
        }
    }
 
    index = index % 2;
 
    if(index === 0) {
        return false;
    } else {
        return true;
    }
}

這個用了經典的射線法去判斷某個點是否在一個多邊形內

原理就是在多邊形外面取一點,然后和判斷的點相連,與多邊形相交個數為偶數則點不在多邊形內,為奇數則在多邊形內

// 計算斜率
k = (end.y - start.y) / (end.x - start.x); // 連線的斜率
kk = -1  / k; // 連線的垂直平分線的斜率
// 判斷起點終點是否在障礙物的AABB內
limit = getAABB(obstacles);
// 矯正位置
var currStart = redressPoint(start, end, limit);
var currEnd = redressPoint(end, start, limit);
 
function redressPoint(aPoint, bPoint, limit) {
    var tmpX;
    var tmpY;
    var tmpPoint;
    if(isInAABB(aPoint, limit)) { // 如果點在障礙物的AABB內就矯正位置
        if(bPoint.x > aPoint.x) { // 計算兩點x的相對位置,這里先以x來偏移y
            tmpX = limit.minX; // 如果a點x小于b點,那么就默認移動到AABB中最小的x點位置
            tmpY = aPoint.y - (aPoint.x - tmpX) * k; // 根據斜率算y的偏移
        } else { // 與上同理
            tmpX = limit.maxX;
            tmpY = aPoint.y - (aPoint.x - tmpX) * k;
        }
        tmpPoint = {
            x: tmpX,
            y: tmpY
        };
 
        if(tmpY < limit.maxY && tmpY > limit.minY) { // 根據剛剛矯正偏移的x,y判斷是否出了AABB,沒有則以y為基準偏移x重新矯正
            if(bPoint.y > aPoint.y) {
                tmpY = limit.minY;
                tmpX = aPoint.x - (aPoint.y - tmpY) / k;
            } else {
                tmpY = limit.maxY;
                tmpX = aPoint.x - (aPoint.y - tmpY) / k;
            }
 
            tmpPoint = { // 該點就是矯正點
                x: tmpX,
                y: tmpY
            };
        }
    } else {
        tmpPoint = aPoint; // 無須矯正
    }
 
    if(isDebug) { // debug模式下顯示矯正的點在哪
        drawCircle(tmpPoint, 'yellow');
    }
    return tmpPoint;
}

這里是矯正起點終點的代碼,其中主要都是關于斜率的數學計算,感興趣的同學可以走一遍看看,注意其中先是按x為基準根據斜率算出矯正后的y,得到新的(x,y),然后判斷這個點是否出了障礙物的AABB,沒有的話重新按y為基準去算x,兩種情況肯定有一種會將點矯正出障礙物的AABB

function check(start, end, obstacles) {
    var isCollision = true; // 結束判斷符
    var dir = 0; // 方向
 
    isBodyCross(start, end, obstacles);
 
    function isBodyCross(currStart, currEnd, obstacles) {
        var unit = 50; // 每次平移的基數單位
        var index = 0; // 偏移的次數
        var changeX = 0; // 平移x方向的具體改變量
        var changeY = 0; // 平移y方向的具體改變量
        var door1 = true;
        var door2 = true;
 
        k = (currEnd.y - currStart.y) / (currEnd.x - currStart.x); // 連線的斜率
        kk = -1  / k; // 連線的垂直線的斜率
 
        if(Math.abs(k) > 1) { // 以x為單位的變動
            changeX = 10;
            changeY = 10 * kk;
        } else { // 以y為單位的變動
            changeX = 10 / kk;
            changeY = 10;
        }
        while(isCollision) { // 當不碰撞的時候結束循環
            if(index > 1000) { // 增加一個界限
                alert('Error');
                return ; 
            }
            // 開始某個方向的尋路
            door1 = true;
            nextStart = {
                x: currStart.x + changeX * index,
                y: currStart.y + changeY * index
            };
            nextEnd = {
                x: currEnd.x + changeX * index,
                y: currEnd.y + changeY * index
            };
            for(var i = 0; i < obstacles.length - 1; i++) {
                result = isLineCross(nextStart, nextEnd, obstacles[i], obstacles[i + 1]);
                if(result) { // 連線碰撞
                    x1 = obstacles[i]; // 最后碰撞邊上的點
                    y1 = obstacles[i + 1]; // 最后碰撞邊上的點
                    door1 = false;
                    drawRole(nextStart, nextEnd);
                    break;
                }
            }
            if(door1) { // 沒有阻礙
                dir = 1;
                isCollision = false;
                break;
            }
            // 另一個方向的尋路
            door2 = true;
            nextStart = {
                x: currStart.x - (changeX * index),
                y: currStart.y - (changeY * index)
            };
            nextEnd = {
                x: currEnd.x - (changeX * index),
                y: currEnd.y - (changeY * index)
            };
            for(i = 0; i < obstacles.length - 1; i++) {
                result = isLineCross(nextStart, nextEnd, obstacles[i], obstacles[i + 1]);
                if(result) {
                    x2 = obstacles[i];
                    y2 = obstacles[i + 1];
                    door2 = false;
                    drawRole(nextStart, nextEnd);
                    break;
                }
            }
            if(i === obstacles.length - 1) { // 檢測完障礙物上的邊,沒有碰撞
                dir = -1;
                isCollision = false;
                break;
            }
 
            index++;
        }
 
        if(door1 || door2) {
            fixPoint(changeX, changeY); // 確定必過點
        }
    }
    
 
    function fixPoint(changeX, changeY) {
        var dis1;
        var dis2;
        var f;
        var x, y;
 
        if(dir === 1) {
            x = x1;
            y = y1;
        } else {
            x = x2;
            y = y2;
        }
 
        if(typeof x === 'undefined') {
            return ;
        }
        // 算距離
        dis1 = distance(x, nextStart, nextEnd);
        dis2 = distance(y, nextStart, nextEnd);
 
        var symbolX;
        var symbolY;
        if(dis1 < dis2) { // 比較距離的大小,得出必過點,此時x是必過點
            // 下面兩個if用來將必過點往整個障礙物外面偏移一點
            if(x.y > y.y) {
                symbolY = 1;
            } else {
                symbolY = -1;
            }
            if(x.x > y.x) {
                symbolX = 1;
            } else {
                symbolX = -1;
            }
            f = { // 偏移后的必過點
                x: x.x + (5 * symbolX),
                y: x.y + (5 * symbolY)
            };
            drawCircle(f, 'green');
        } else { // 必過點是y
            if(y.y > x.y) {
                symbolY = 1;
            } else {
                symbolY = -1;
            }
            if(y.x > x.x) {
                symbolX = 1;
            } else {
                symbolX = -1;
            }
            f = { // 最終必過點的位置
                x: y.x + (5 * symbolX),
                y: y.y + (5 * symbolY)
            };
            drawCircle(f, 'pink');
        }
 
        // 將必過點添加到最終路徑
        f.index = end.index;
        globalPath.splice(f.index, 0, f);
        for(var i = 0; i < globalPath.length; i++) {
            globalPath[i].index = i;
        }
        // 下一輪計算前這些值設置為undefined
        x1 = undefined;
        y1 = undefined;
        x2 = undefined;
        y2 = undefined;
 
        var tmp = redressPoint(globalPath[f.index - 1], f, limit); // 矯正
        check(tmp, f, obstacles);
        tmp = redressPoint(globalPath[f.index + 1], f, limit); // 矯正
        check(f, tmp, obstacles);
    }
}

核心的代碼,先看外面的dir,它是方向,因為這個平行是要按照兩個方向去判斷的

unit值的大小決定平移的精度,我自己用的是50px,感覺還不錯,如果這個值過大,可能會出現跳過障礙物某條邊的情況,如果這個值過小,會影響性能

changeX和changeY是x方向和y方向變動的值,根據斜率計算出來的

然后while循環就開始不停的找到每一個必過點,而確定必過點就是通過fixPoint函數,dis1和dis2是用來計算長度判斷一個邊上的兩個點哪個是必過點,前面有說過。symbolX和symbolY是當時有些畫蛇添足的一步,用來 把找到的必過點向外偏移一點 ,方便看清楚,看demo效果挺好,不過實際上是不需要的

——————————————————我是分隔符————————————————————

說了這么多,也貼出了在線演示地址,簡單易操作,就不說了,下面說一下這個方法不足的地方

有一個不足,但也是一個很致命的不足,就是一旦 起點或者終點就在障礙物的AABB內,而且這個AABB內很怪異的時候 ,會出現問題,下面舉出例子

一旦起點或者終點被障礙物這樣詭異的包圍的時候,整個尋路就會出現問題,即使我們將點矯正出障礙物的AABB也不行

原因的本質是我們的算法核心是 連線平移去繞過障礙物 ,只要出現 連線平移穿過障礙物 的情況那就違背繞的本質

有問題我們自然要解決

我嘗試了一些方法,比如分割障礙物,多次矯正等等,都不合適,本身算法核心的問題再怎么hack也沒辦法

那怎么辦,看之前標注的①,我們將關注點由連線變成多邊形上的每個點,因為每個點都可能是必過點不是么~

下文會詳細講解另外一種方法

最后:

既然該方法有致命的弱點,那它能用嗎?

我認為是可以的,但有一定的條件,就是重點和起點盡量不要在障礙物的AABB內, 障礙物不是多邊形也沒事 ,這是該方法優勢的一個情況,目前我們的障礙物都是多邊形,如果是圓呢?因為該方法考慮的是起點和終點的連線,對障礙物幾乎沒有要求,只需要根據平行后的連線算出必過點就可以了,所以對障礙物形狀沒有要求,只有對位置有一定的要求

好了,這篇文章就說到這兒了,下篇文章我們將以另一個角度去尋路,無視起點終點被詭異的障礙物包裹

請看下文:

http://www.alloyteam.com/2016/03/with-a-star-under-different-pixel-pathfinding-algorithm/

</div>

 本文由用戶 avtl8653 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!