寫在前面
- 本次項目Github地址:傳送門
- 本次項目的視頻演示地址(相比之前增加智能提示的步驟):傳送門
- 項目的詳細內容見潘老師的課程網站:網站鏈接
- 上一個版本的內容,查看我之前的博客:牧師與魔鬼動作分離版
實驗內容
- 實現狀態圖的自動生成
- 講解圖數據在程序中的表示方法
- 利用算法實現下一步的計算
- 參考:P&D 過河遊戲智能幫助實現
(跑過了自己的算法之後,發現在參考博客裏面,發現其實有個地方是有錯的。它的狀態圖是用起始岸的魔鬼與牧師數來表示的,另一邊岸就可以通過3減去起始岸的角色數量來得到。)
截自參考博客的狀態圖:
瞭解遊戲規則都知道,這個狀態是不能存在的,因爲另一邊就是1P2D,遊戲結束了。
整個狀態圖裏面,除非兩者都是2,否則不可能出現2P這樣的狀態。這個應該是博主的一個小錯誤。
狀態圖自動生成(使用DFS)
自動生成過程可以利用搜索算法來實現,實際上我們都可以知道整個狀態圖的狀態數其實不是很多(畢竟要適合用戶玩,遊戲難度本來就不太高),所以搜索過程實際上也是很快就能夠得出解的。而關鍵在於如何設計狀態的轉移,如何將其程序實現實現?首先搜索算法中需要表示每一個狀態,然後就是狀態到狀態的轉移的表示,最後就是算法的設計(包括Closed表、最佳路徑等的生成)。
1. 狀態表示
每一個狀態都可以看成由兩個部分組成:角色的數量、船的位置。
角色的數量又可以看情況分爲:河兩岸的人數、每一邊牧師數量和魔鬼的數量
由於是深度優先搜索,所以還需要記錄到下一個節點的狀態,類似樹的結構。爲了方便各個狀態之間的雙向轉移,可以構建一個雙向鏈表,指向父節點。
結構體如下:
public class State{
public int priest;
public int devil;
public bool boat;
public State parent; // 記錄深搜時從哪一個狀態擴展出來,沒什麼重要用途
public State best_way; //最佳路徑,遍歷全部狀態後得到一條通向解的路徑
public State() {}
public State(int p, int d, bool b) {
this.priest = p;
this.devil = d;
this.boat = b;
}
public State(int p, int d, bool b, State par) {
this.priest = p;
this.devil = d;
this.boat = b;
this.parent = par;
}
public State(State copy) {
this.priest = copy.priest;
this.devil = copy.devil;
this.boat = copy.boat;
this.parent = copy.parent;
this.best_way = copy.best_way;
}
public bool isEqual(State compare) {
return this.priest==compare.priest && this.devil==compare.devil && this.boat == compare.boat;
}
// override object.Equals
public override bool Equals(object obj)
{
if (obj == null || GetType() != obj.GetType())
{
return false;
}
State tmp = (State)obj;
return this.priest==tmp.priest && this.devil==tmp.devil && this.boat == tmp.boat;
}
// override object.GetHashCode
public override int GetHashCode()
{
throw new System.NotImplementedException();
}
public override String ToString() {
if (best_way == null) {
return "priest: " + priest.ToString() + " devil: " + devil.ToString() + " boat: " + boat.ToString() +
"\nNext: " + "NULL";
}
return "priest: " + priest.ToString() + " devil: " + devil.ToString() + " boat: " + boat.ToString() +
"\nNext: " + best_way.priest.ToString() + " " + best_way.devil.ToString() + " " + best_way.boat.ToString();
}
}
這個類定義了上述可以表示狀態的一些變量,表示牧師與魔鬼的數量時,只需要記錄起始岸邊(左邊河岸)的數量就好的,因爲總數已知,所以可以通過總數減左邊河岸得到右邊河岸的數量,沒有必要再另外存儲。
定義了幾個不同簽名的構造函數,方便創建狀態。
重載了Equals
函數,便於使用List等集合結構來存儲。
重載了ToString
函數, 便於打印當前狀態的信息。
2.DFS算法實現
DFS只需要從一個狀態轉移到另一個狀態,就需要定義轉移的操作。
- 可以明確的是一定需要有人在船上,才能發生轉移;
- 有船的岸邊才能載人;
- 而且船的轉移一定是從一個岸邊轉移到另一個岸邊;
除了以上固定的轉移規則,其餘的規則定義如下:
- 一次轉移一個牧師/兩個牧師
- 一次轉移一個魔鬼/兩個魔鬼
- 一次轉移一個魔鬼和一個牧師
轉移的方法就是將有船的一邊人數減少,另一邊人數增加;但是我們狀態只記錄了左岸的人數,所以當船在左岸時,發生轉移則人數減少;當船在右岸時,發生轉移則人數增加。
每次深搜就是從這些轉移的狀態中找一個,繼續搜索下去,注意只能是有效狀態才能夠繼續搜索。有效狀態的定義就是沒有觸發遊戲結束條件的狀態。
算法類代碼:
public class AI {
public static List<State> closed = new List<State>();
public static State end = new State(0, 0, true);
public bool isFind = false;
public bool DFS(ref State root) {
closed.Add(root);
if (root.isEqual(end)) {
isFind = true;
}
for (int i = 0; i < 5; i ++) {
State next = nextState(root, i);
if (next != null) {
if (closed.Contains(next))
continue;
next.parent = root;
if (isFind) {
next.best_way = root;
}
else {
closed.Remove(root);
root.best_way = next;
closed.Add(root);
}
DFS(ref next);
}
}
if (!root.isEqual(end) && root.best_way == null) {
root.best_way = root.parent;
}
return isFind;
}
public void print() {
for (int i = 0; i < closed.Count; i ++) {
Debug.Log(closed[i].ToString());
}
}
public static bool isValid(State s) {
if (s.priest != 0 && s.priest < s.devil) { // 左邊有牧師且 牧師人數不應少於魔鬼
return false;
}
if (s.priest != 3 && (3-s.priest) < (3-s.devil)) { //右邊有牧師且 牧師人數不應少於魔鬼
return false;
}
return true;
}
public State nextState(State s, int operation) {
int p, d;
bool b;
p = s.priest;
d = s.devil;
b = s.boat;
State next = null;
if (b) { // 船在右方
if (operation == 0) {
if (3-p >= 1) { // 右方牧師大於1人,可過
next = new State(p+1, d, !b);
}
else {
return null;
}
}
else if (operation == 1) {
if (3-p >= 2) { // 右方牧師大於2人,可過
next = new State(p+2, d, !b);
}
else {
return null;
}
}
else if (operation == 2) {
if (3-d >= 1) { // 右方魔鬼大於1人,可過
next = new State(p, d+1, !b);
}
else {
return null;
}
}
else if (operation == 3) {
if (3-d >= 2) { // 右方魔鬼大於1人,可過
next = new State(p, d+2, !b);
}
else {
return null;
}
}
else if (operation == 4) {
if(3-p >= 1 && 3-d >= 1) {
next = new State(p+1, d+1, !b);
}
else {
return null;
}
}
}
else { // 船在左方
if (operation == 0) {
if (p >= 1) {
next = new State(p-1, d, !b);
}
else {
return null;
}
}
else if (operation == 1) {
if (p >= 2) {
next = new State(p-2, d, !b);
}
else {
return null;
}
}
else if (operation == 2) {
if (d >= 1) {
next = new State(p, d-1, !b);
}
else {
return null;
}
}
else if (operation == 3) {
if (d >= 2) {
next = new State(p, d-2, !b);
}
else {
return null;
}
}
else if (operation == 4) {
if (p >= 1 && d >= 1) {
next = new State(p-1, d-1, !b);
}
else {
return null;
}
}
}
if (isValid(next)) {
return next;
}
return null;
}
}
創建一個closed表,存放已經訪問過的節點。搜索過程中,利用list.contain來判斷當前狀態是否已經訪問過,如果訪問過就不再拓展。
深搜的過程相信都很熟悉,就不再展開。只不過這裏的深搜實際上需要遍歷到所有的狀態,即使找到了一條正確的狀態轉移路徑也不會馬上停止,而是需要找到所有狀態,並且找出它下一步的最佳走法。
比如說:一個狀態無法在往下擴展,所以他的最佳狀態就只能是他的父節點狀態。
對於一個父節點,最佳狀態就是當前在搜索的那一條路徑。如果這條路徑又回溯回來,就將最佳狀態設爲下一條搜索路徑。如果這個節點被訪問過,但是狀態又發生改變的話,就需要從closed表中取出,再重新加入。
至於對下一個狀態的尋找,主要是分別根據以上所列幾種狀態轉移來判斷,如果沒有足夠的人轉移,則返回;狀態生成後,還需要判斷牧師與魔鬼的數量是否符合規則,如果不符合返回null。
搜索結束後,得到的結果全部存在了closed表中,closed表中存放的是一個個狀態,每個狀態都包含了自身信息,以及下一個最佳轉移狀態。通過這個轉移,就可以得到一條通向結果的路徑。
3.DFS生成結果
將closed表中的元素全部打印出來得到以下結果:(牧師魔鬼的數量只有在左邊岸上的數量,船的狀態:False表示在左岸,True表示在右岸)
priest: 3 devil: 2 boat: True
Next: 3 3 False
priest: 3 devil: 3 boat: False
Next: 3 1 True
priest: 3 devil: 1 boat: True
Next: 3 2 False
priest: 2 devil: 2 boat: True
Next: 3 2 False
priest: 3 devil: 2 boat: False
Next: 3 0 True
priest: 3 devil: 0 boat: True
Next: 3 1 False
priest: 3 devil: 1 boat: False
Next: 1 1 True
priest: 1 devil: 1 boat: True
Next: 2 2 False
priest: 2 devil: 2 boat: False
Next: 0 2 True
priest: 0 devil: 2 boat: True
Next: 0 3 False
priest: 0 devil: 3 boat: False
Next: 0 1 True
priest: 0 devil: 1 boat: True
Next: 1 1 False
priest: 1 devil: 1 boat: False
Next: 0 0 True
priest: 0 devil: 0 boat: True
Next: NULL
priest: 0 devil: 1 boat: False
Next: 0 0 True
priest: 0 devil: 2 boat: False
Next: 0 0 True
爲了更直觀地看結果,我按照以上信息,做了一個圖:
箭頭方向代表尋找最優解的路徑方向,每一個狀態都有一個最優的轉移狀態,這也是智能提示所做的工作:幫助玩家從當前狀態更快走到結束狀態。也就是判斷當前玩家的狀態,然後根據next來進行轉移。
更改Controller
在Controller開始,就通過AI的類,使用DFS計算出所有狀態的轉移路徑,這樣就會存在AI類中的closed表裏面,隨時可以取用。
實現交互功能,首先需要添加一個新的接口,也就是我們新加的功能,並且實現它:
public void getTips() {
if (forbid) return;
if (boat.getCount()[0] != 0 || boat.getCount()[1] != 0) {
for (int i = 0; i < 2; i ++) {
if (boat.getChar(i) != null)
setCharacterPosition(boat.getChar(i));
}
}
int[] count = leftBank.getCount();
int d = count[0];
int p = count[1];
bool b = boat.getLR()==1;
State current = new State(p,d,b);
State next = AI.closed.Find((State s) => {return s.isEqual(current);}).best_way;
Debug.Log("current: " + current);
Debug.Log("next: " + next);
if (next == null) return;
if (b) {
int d2 = next.devil - d;
int p2 = next.priest - p;
while (d2 > 0 || p2 > 0) {
for (int i = 0; i < 6; i ++) {
if (characters[i].getBank() != null && characters[i].getBank().getLR() == 1) {
if (d2 > 0 && characters[i].getMan() == "Devil") {
setCharacterPosition(characters[i]);
d2 --;
break;
}
if (p2 > 0 && characters[i].getMan() == "Priest") {
setCharacterPosition(characters[i]);
p2 --;
break;
}
}
if (i==5){
Debug.Log("Err");
return;
}
}
}
}
else {
int d2 = -next.devil + d;
int p2 = -next.priest + p;
while (d2 > 0 || p2 > 0) {
for (int i = 0; i < 6; i ++) {
if (characters[i].getBank() != null && characters[i].getBank().getLR() == 0) {
if (d2 > 0 && characters[i].getMan() == "Devil") {
setCharacterPosition(characters[i]);
d2 --;
break;
}
if (p2 > 0 && characters[i].getMan() == "Priest") {
setCharacterPosition(characters[i]);
p2 --;
break;
}
}
if (i==5){
Debug.Log("Err");
return;
}
}
}
}
MoveBoat();
}
首先統計當前人數以確定當前狀態,爲了方便統計,所以需要先把船上的角色先重新移回岸上(之前的接口設計不完善),由於之前移動角色是用到了動作,有一個時間的問題,這裏代碼是連續執行的,就會起矛盾,因爲這裏直接調用了move的動作函數,但是又不能直接設置回調,所以難以修改。所以新建了一個函數,直接改變角色的位置,取消了動作執行的過程。
統計人數並且得出狀態後,根據狀態的next,構建一個目標狀態,根據這個目標的狀態選擇上下船的人數,最後執行moveBoat()完成一次提示。而這個接口可以綁定在UI的一個按鈕上(使用IMGUI實現),然後按鈕被調用就執行提示。