unity學習:尋路演算法(AStar演算法)與簡單AI(勢能場估價演算法)
專案地址:https://github.com/kotomineshiki/AIFindPath
視訊地址:多重尋路
綜合尋路——包括攻擊考量的尋路演算法
GamePlay
這是一個《文明》+皇室戰爭的組合。
UI層使用狀態機來實現以下操作
1. 點選棋子再點選格子,棋子移動向格子(AStar演算法)
2. 點選棋子會彈出該棋子的屬性介紹面板,再點選一次取消
3. 點選格子會彈出該格子的屬性介紹面板,再點選一次取消
4. 點選底部召喚板,再點選格子,會在該格子處召喚一個棋子
5. 點選召喚板未點選格子時,會在滑鼠停留的格子上出現一個虛影
6. 點選棋子再點選另一個棋子的時候,該棋子移動向另一個棋子,如果他們處於敵對陣營,則該棋子列入攻擊列表
一些bug:
AStar演算法不要使用多執行緒,因為如果節點情況有變,則會產生執行緒合併的衝突導致抖動。
其實Astar演算法和作為AI的勢能場演算法是有衝突的,下次重構的時候應該特別注意只使用勢能場。因為勢能場本身就已經包括了尋路演算法,所以沒必要特意寫一個尋路演算法增加程式碼複雜度,這是這次經驗不足導致的。
兩個核心難點
Astar演算法
先是自己實現了一遍,後來找到了效率更高的外掛就重構了
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MyAStar {
public ArrayList openList;
public ArrayList closeList;
public int targetX;
public int targetY;
public Vector2Int start;
public Vector2Int end;
public Stack<string> parentList;//結果棧,存的格式是xy
public List<Vector2Int> resultPath;//結果佇列
//public Transform plane;
//public Transform obstacle;
private float alpha = 0;
private float incrementPer = 0;
public void SetStartGrid(int x, int y)
{
start.x = x ;
start.y = y;
GridMap.instance.grids[x, y].SetGridType(GridType.Start);
openList.Add(GridMap.instance.grids[x, y]);
}
public void SetEndGrid(int x, int y)
{
end.x = x;
end.y = y;
GridMap.instance.grids[x, y].SetGridType(GridType.End);
}
public void Clear()
{
GridMap.instance.grids[start.x, start.y].SetGridType(GridType.Normal);
GridMap.instance.grids[end.x, end.y].SetGridType(GridType.Normal);//恢復初始狀態
for(int i = 0; i < GridMap.instance.row; ++i)
{
for(int j = 0; j < GridMap.instance.column; ++j)
{
GridMap.instance.grids[i, j].parent = null;
GridMap.instance.grids[i, j].f = 0;
GridMap.instance.grids[i, j].g = 0;
GridMap.instance.grids[i, j].h = 0;
}
}
parentList.Clear();
openList.Clear();
closeList.Clear();
resultPath.Clear();
}
public MyAStar()//初始化函式
{
parentList = new Stack<string>();
openList = new ArrayList();
closeList = new ArrayList();
resultPath = new List<Vector2Int>();
Debug.Log("初始化完成");
}
public void Calculate()
{
Debug.Log("開始尋找路徑");
//yield return new WaitForSeconds(0.1f);
//openList.Add(grids[startX, startY]);
MyGrid currentGrid = openList[0] as MyGrid;
while (openList.Count > 0 && currentGrid.gridType != GridType.End)
{
currentGrid = openList[0] as MyGrid;
if (currentGrid.gridType == GridType.End)
{
Debug.Log("找到路徑");
GenerateResult(currentGrid);
}
for(int i = -1; i <= 1; i++)
{
for(int j = -1; j <= 1; j++)
{
if (i != 0 || j != 0)
{
int x = currentGrid.x + i;
int y = currentGrid.y + j;
if (x >= 0 && y >= 0 && x < GridMap.instance.row && y < GridMap.instance.column
&& GridMap.instance.grids[x, y].landForm != LandForm.Obstacle
&& !closeList.Contains(GridMap.instance.grids[x, y]))
{
int g = currentGrid.g + (int)(Mathf.Sqrt((Mathf.Abs(i) + Mathf.Abs(j))) * 10);
if (GridMap.instance.grids[x, y].g == 0 || GridMap.instance.grids[x, y].g > g)
{
GridMap.instance.grids[x, y].g = g;
GridMap.instance.grids[x, y].parent = currentGrid;
}
GridMap.instance.grids[x, y].h = Manhattan(x, y);
GridMap.instance.grids[x, y].f = GridMap.instance.grids[x, y].g + GridMap.instance.grids[x, y].h;
if (!openList.Contains(GridMap.instance.grids[x, y]))
{
openList.Add(GridMap.instance.grids[x, y]);
}
openList.Sort();
}
}
}
}
closeList.Add(currentGrid);
openList.Remove(currentGrid);
if (openList.Count == 0)
{
Debug.Log("未找到路徑");
}
}
}
void GenerateResult(MyGrid currentGrid)
{
if (currentGrid.parent != null)
{
parentList.Push(currentGrid.x + "|" + currentGrid.y);
resultPath.Add(new Vector2Int(currentGrid.x, currentGrid.y));
GenerateResult(currentGrid.parent);
}
}
IEnumerator ShowResult()
{
Debug.Log("顯示開始"+parentList.Count);
yield return new WaitForSeconds(0.3f);
incrementPer = 1 / (float)parentList.Count;
while (parentList.Count != 0)
{
Debug.Log("走一步"+ parentList.Count);
string str = parentList.Pop();
yield return new WaitForSeconds(0.3f);
string[] xy = str.Split(new char[]
{
'|'
});
int x = int.Parse(xy[0]);
int y = int.Parse(xy[1]);
alpha += incrementPer;
GridMap.instance.objs[x, y].transform.GetChild(0).GetComponent<MeshRenderer>().material.color
= new Color(1 - alpha, alpha, 0, 1);
}
}
int Manhattan(int x,int y)//曼哈頓距離
{
return (int)(Mathf.Abs(targetX - x) + Mathf.Abs(targetY - y)) * 10;
}
}
AI部分
AI移動傾向需要滿足的要求(按照意願的優先順序從高到低):
但友軍在和敵人交戰的時候,很願意前去支援
願意和同陣營的棋子在一起(夾擊有加成,更加安全)
願意前往距離敵軍遠的、屬於敵人的格子(可以佔領地盤)
願意前往比較近的地方,而不願意前往比較遠的地方。
AI攻擊需要滿足的要求(按照意願的優先順序從高到低):
當有友軍在攻擊敵軍時,會很願意攻擊被友軍攻擊的敵人
當自己血量較低的時候,不願意進行攻擊,願意逃跑
當附近有敵人的時候,會進行攻擊
實現方法:勢能場+貪心演算法
實現思路:給每個格子賦予一個優先度屬性,每滿足一個條件可以增加一些優先度(勢能),每次需要做出決斷的時候,計算對於該棋子的當前場面上的所有的格子(計算勢能場分佈),尋找估價最高的格子並選擇前往(此處和勢能場稍微有些不同,一般的勢能場尋路演算法是循著導數梯度到達勢能的極值點,而因為尋路演算法已經選用了AStar演算法,就不再需要此處再寫了—注意此處隱藏了一個衝突)每一次判斷都選用對於當前局面能選擇的最優秀的決策。
攻擊判定:每到達一個格子會判斷是繼續行走還是對在該格子攻擊範圍內的敵人進行攻擊。攻擊動作也有一個估價函式。
每次完成一次行走或者攻擊或者路徑被中斷時,就會向ChessManager通過觀察者模式進行詢問,查詢下一步該做的事。
勢能場介紹:2D的勢能場是圍繞某一個點(或者設計者期望擁有的屬性)進行一個估值,總勢能場是所有估值的疊加。AI應該選擇當前附近勢能變化最快(梯度)的走,也可以選擇周圍勢能最高點作為目的地。對於障礙物,應該把勢能設定為(無窮小|無窮大),這樣在選擇路徑的時候,AI會“討厭”往障礙物處走
using AStar_2D;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Thinking : MonoBehaviour {//這個類是用來估值並決策的
public int[,] Value;//用來計算各個格子的估值的
public int X;
public int Y;
public Camp myself;
public List<Index> answer;
public List<Chess> canAttack;
public ArrayList AttackValue;
// Use this for initialization
void Start() {
X = GameManager.instance.gridMap.gridX;
Y = GameManager.instance.gridMap.gridY;
answer = new List<Index>();
canAttack = new List<Chess>();
myself = this.GetComponent<Chess>().camp;
Value = new int[X, Y];
AttackValue = new ArrayList();
Clear();
//GetComponent<Thinking>().SeekWhatToDo();
//StartCoroutine(trythink());
}
/*IEnumerator trythink()
{
yield return new WaitForSeconds(1);
SeekWhatToDo();
}*/
void Clear()
{
for (int i = 0; i < X; ++i)
{
for (int j = 0; j < Y; ++j)
{
//Debug.Log(i + " s" + j);
Value[i, j] = 0;//全部資料清零
}
}
}
// Update is called once per frame
void Update() {
}
public void SeekWhatToDo()//做出決策
{
Clear();
answer.Clear();
canAttack.Clear();
AttackValue.Clear();
EvaluatingMove();//生成價值矩陣,然後找最大
EvaluatingAttackAction();//尋找攻擊價值最高的
ArrayList temp=new ArrayList();
for (int i = 0; i < X; ++i)
{
for (int j = 0; j < Y; ++j)
{
temp.Add(Value[i, j]);
}
}
temp.Sort();
temp.Reverse();
for(int i = 0; i < temp.Count; ++i)
{
Find((int)temp[i]);
}
if(canAttack.Count==0)//無法打的時候走路
this.GetComponent<MyAgent>().setDestination(answer[0]);//前往最優點
else//可以打的時候打人
{
ArrayList temp2 = new ArrayList(AttackValue);
temp2.Sort();
temp2.Reverse();
this.GetComponent<Chess>().Attack(FindAttack((int)temp2[0]));
}
}
void Find(int input)
{
for (int i = 0; i < X; ++i)
{
for (int j = 0; j < Y; ++j)
{
if (Value[i, j] == input&&answer.Contains(new Index(i,j))==false)//值相等且不再列表裡
{
answer.Add(new Index(i, j));
}
}
}
}
Chess FindAttack(int input)
{
for(int i = 0; i < canAttack.Count; ++i)
{
if ((int)AttackValue[i] == input)
{
return canAttack[i];
}
}
return null;
}
void EvaluatingAttackAction()//判斷打架這個行為是否合算
{
if (Camp.PlayerA == myself)
{
}
else if(Camp.PlayerB==myself)
{
foreach(var i in GameManager.instance.chessManager.playerA)
{
if (this.GetComponent<Chess>().CanAttack(i.GetComponent<Agent>().GetCurrentIndex()))
{
canAttack.Add(i);
}
}//這樣獲得了可以供攻擊的列表,下面判斷攻擊的價值
for(int i = 0; i < canAttack.Count; ++i)
{
int amount=0;
amount += 110-(int)canAttack[i].hp; //血越少越有打的價值
foreach(var temp in GameManager.instance.chessManager.playerA)
{//如果有友軍在打,則應該優先攻打
if (temp.isAttacking == canAttack[i])
{
amount += 40;
}
}
AttackValue.Add(amount);
}
}
}
void EvaluatingMove()
{//AI移動傾向需要滿足的要求(按照意願的優先順序從高到低):
EvaluatingRadius(this.GetComponent<Agent>().GetCurrentIndex());
if (Camp.PlayerA == myself)
{
foreach (var i in GameManager.instance.chessManager.playerA)
{
EvaluatingFriend(this.GetComponent<Agent>().GetCurrentIndex());//願意和同陣營的棋子在一起(夾擊有加成,更加安全)
if (i.isAttacking)//友軍在和敵人交戰的時候,很願意前去支援
{
EvaluatingAttacking(i.ToAttack[0].GetComponent<Agent>().GetCurrentIndex());
}
}
foreach(var i in GameManager.instance.chessManager.playerB)
{
EvaluatingEnemy(i.GetComponent<Agent>().GetCurrentIndex());
}
for (int i = 0; i < X; ++i)
{
for (int j = 0; j < Y; ++j)
{
if (GameManager.instance.gridMap.tiles[i, j].tileType == Camp.Nobody)//無人佔領區有比較大的吸引力
{
Value[i, j] += 2;
}
if (GameManager.instance.gridMap.tiles[i, j].tileType == Camp.PlayerB)//鼓勵進攻
{
Value[i, j] += 1;
}
}
}
//願意前往距離敵軍遠的、屬於敵人的格子(可以佔領地盤)
}
else if (Camp.PlayerB == myself)
{
foreach (var i in GameManager.instance.chessManager.playerB)
{
EvaluatingFriend(this.GetComponent<Agent>().GetCurrentIndex());
if (i.isAttacking)//友軍在和敵人交戰的時候,很願意前去支援
{
EvaluatingAttacking(i.ToAttack[0].GetComponent<Agent>().GetCurrentIndex());
}
}
foreach (var i in GameManager.instance.chessManager.playerA)
{
EvaluatingEnemy(i.GetComponent<Agent>().GetCurrentIndex());
}
for (int i = 0; i < X; ++i)
{
for (int j = 0; j < Y; ++j)
{
if (GameManager.instance.gridMap.tiles[i, j].tileType == Camp.Nobody)//無人佔領區有比較大的吸引力
{
Value[i, j] += 2;
}
if (GameManager.instance.gridMap.tiles[i, j].tileType == Camp.PlayerA)//鼓勵進攻
{
Value[i, j] += 1;
}
}
}
//願意前往距離敵軍遠的、屬於敵人的格子(可以佔領地盤)
}
}
void EvaluatingRadius(Index position)//傳入自己的地址,以自己為半徑來製造一個個等勢面
{
for (int distance = 1; distance < 7; ++distance)
{
Index tempA = position + new Index(-distance, distance);
Index tempB = position + new Index(distance, distance);
Index tempC = position + new Index(-distance, -distance);
Index tempD = position + new Index(distance, -distance);
for (int i = tempA.X; i <= tempB.X; ++i)
{//上面一行
if (IsValid(new Index(i, tempA.Y)))
{
// Debug.Log(i+ " "+ tempA.Y);
Value[i, tempA.Y] += 7 - distance;
}
}
for (int i = tempC.X; i <= tempD.X; ++i)
{//下面一行
if (IsValid(new Index(i, tempC.Y)))
{
Value[i, tempC.Y] += 7 - distance;
}
}
for (int i = tempC.Y + 1; i < tempA.Y; ++i)
{//左邊
if (IsValid(new Index(tempA.X, i)))
{
Value[tempA.X, i] += 7 - distance;
}
}
for (int i = tempD.Y + 1; i < tempB.Y; ++i)
{//右邊
if (IsValid(new Index(tempD.X, i)))
{
Value[tempD.X, i] += 7 - distance;
}
}
}
}
void EvaluatingAttacking(Index position)//傳入敵人的地址,敵人周圍的格子魅力增加
{
int distance = 1;
Index tempA = position + new Index(-distance, distance);
Index tempB = position + new Index(distance, distance);
Index tempC = position + new Index(-distance, -distance);
Index tempD = position + new Index(distance, -distance);
for (int i = tempA.X; i <= tempB.X; ++i)
{//上面一行
if (IsValid(new Index(i, tempA.Y)))
{
Value[i, tempA.Y] += 10;
}
}
for (int i = tempC.X; i <= tempD.X; ++i)
{//下面一行
if (IsValid(new Index(i, tempC.Y)))
{
Value[i, tempC.Y] += 10 ;
}
}
for (int i = tempC.Y + 1; i < tempA.Y; ++i)
{//左邊
if (IsValid(new Index(tempA.X, i)))
{
Value[tempA.X, i] += 10 ;
}
}
for (int i = tempD.Y + 1; i < tempB.Y; ++i)
{//右邊
if (IsValid(new Index(tempD.X, i)))
{
Value[tempD.X, i] += 10 ;
}
}
}
void EvaluatingFriend(Index position)//輸入一個地址,把周圍的格子的魅力+2
{
for(int distance = 1; distance < 5; ++distance)
{
Index tempA = position + new Index(-distance, distance);
Index tempB = position + new Index(distance, distance);
Index tempC = position + new Index(-distance, -distance);
Index tempD = position + new Index(distance, -distance);
for (int i = tempA.X; i <= tempB.X; ++i)
{//上面一行
if(IsValid(new Index(i, tempA.Y)))
{
Value[i, tempA.Y] += 5-distance;
}
}
for (int i = tempC.X; i <= tempD.X; ++i)
{//下面一行
if (IsValid(new Index(i, tempC.Y)))
{
Value[i, tempC.Y] += 5-distance;
}
}
for(int i = tempC.Y + 1; i < tempA.Y; ++i)
{//左邊
if(IsValid(new Index(tempA.X, i)))
{
Value[tempA.X, i] += 5-distance;
}
}
for (int i = tempD.Y + 1; i < tempB.Y; ++i)
{//右邊
if (IsValid(new Index(tempD.X, i)))
{
Value[tempD.X, i] += 5-distance;
}
}
}
}
void EvaluatingEnemy(Index position)//輸入一個地址,把周圍的格子的魅力+2
{
for (int distance = 1; distance < 3; ++distance)
{
Index tempA = position + new Index(-distance, distance);
Index tempB = position + new Index(distance, distance);
Index tempC = position + new Index(-distance, -distance);
Index tempD = position + new Index(distance, -distance);
for (int i = tempA.X; i <= tempB.X; ++i)
{//上面一行
if (IsValid(new Index(i, tempA.Y)))
{
Value[i, tempA.Y] += 5 - distance;
}
}
for (int i = tempC.X; i <= tempD.X; ++i)
{//下面一行
if (IsValid(new Index(i, tempC.Y)))
{
Value[i, tempC.Y] += 5 - distance;
}
}
for (int i = tempC.Y + 1; i < tempA.Y; ++i)
{//左邊
if (IsValid(new Index(tempA.X, i)))
{
Value[tempA.X, i] += 5 - distance;
}
}
for (int i = tempD.Y + 1; i < tempB.Y; ++i)
{//右邊
if (IsValid(new Index(tempD.X, i)))
{
Value[tempD.X, i] += 5 - distance;
}
}
}
}
bool IsValid(Index position)
{
if (position.X >= X||position.X<0) return false;
if (position.Y >= Y || position.Y < 0) return false;
return true;
}
}
為什麼要用AStar+勢能場
勢能場本身其實可以進行尋路,但是在二維的座標世界中,勢能的梯度下降並不是像3維連續的勢能的梯度下降,這就導致了一個bug:可能在等勢面上(兩個同等取值的格子)來回移動。這個是可以避免的(使用一個列表來管理優先前行的位置,前端估值高,後端估值低),發現重複移動則remove掉隊首。但是這次真的沒時間再做這個了,留給暑假騰訊NextIdea裡解決吧。使用AStar還有除此之外的好處:勢能場中的勢能極值點往往是終點(水往低處流),這樣可以獲得比勢能場尋路更短的路徑(但不是最優的路徑,因為勢能場尋路可以選擇被敵人攻擊風險最小的方法)。
結語
在這個作業上實在是傾注了太多心血了,前後重構了三次,雖然最後bug還是蠻多的。我之後又重構了一次,想純粹使用勢能場函式進行AI判斷,結果雖然也可以,但是bug很多,很容易宕機。