這是React井字棋項目的最後一篇筆記,記述AI實現。 一. 是開頭都會說的原理 但凡懂一點圍棋的人都知道“大場”這個概念,可以淺顯地把它理解為佈局時棋盤上各處的要點。棋諺“金角銀邊草肚皮”,就很好地說明瞭大場具有的特征:價值高。 比如沒其他子的情況下,先手占星角位,這手棋價值大約是20目。第一手下 ...
這是React井字棋項目的最後一篇筆記,記述AI實現。
一. 是開頭都會說的原理
但凡懂一點圍棋的人都知道“大場”這個概念,可以淺顯地把它理解為佈局時棋盤上各處的要點。棋諺“金角銀邊草肚皮”,就很好地說明瞭大場具有的特征:價值高。
比如沒其他子的情況下,先手占星角位,這手棋價值大約是20目。第一手下在頂角,價值可能就1-2目。那就如果第一手占天元,價值...就不好說了。
一個棋類游戲AI實現的難度在於,每手棋的價值其實都是人類經驗性的總結。演算法無論是窮舉或進化,都是人在教機器下棋。人的水平高,機器就學得快。反之亦然。
回到九宮格的井字棋,由於場地條件限制,極大地簡化了ai的實現過程。窮舉就可是最現實的辦法。人只需要根據經驗定義每種情況下點位的價值。教會它計算每手棋的價值,再根據價值排序,找出價值最高的一個就可以了。
只考慮一條線路上的情況,當場面出現己方的二子連線時,無疑最佳落子點是這條線的第三點(一著取勝),這手棋對於取勝來說,必然是無價的。但機器不懂人類的價值觀,我們就假定這手棋的價值是10000吧。如果這條線路上對方出現二子連線,此時場面最有價值的點位就是封堵對方二子連線的位置,這手棋價值也很重要,但不能超過己方二子連線的價值,可算作1000。同理,當這條線路上,只有你的一個子,你下這手棋可以獲得在這條線上的先手優勢,有價值,但是比前兩種情況都低,算作100。如果你下一手棋,線路上只有對方的一個子。你落子於此,既不能一步取勝,也不能封堵對方的二子連線,且不能在這條線上獲得先手優勢,那就是廢著,昏著(如同圍棋中在自己的地盤裡填子一樣)——儘管我們那麼評價它,但這在實戰中還是可能出現的,所以它的權重就是-10。
其它情況有沒有考慮到呢?有比如線路一個子都沒有的情況,先手肯定有一點點優勢。但是在井字棋的佈局里還不夠不典型。就定義為0吧——這裡又有坑了。
實際上的情況是,一手棋包含了2-4條線路。綜合各條線路的判斷下來。累加的權重就是這手棋的價值。
二. AI結構
先在外部js上定義一個ai函數;再把這個ai.js引入到header中。回到組件的頁面。在Game組件的渲染方法return前寫console.log(ai(lastHistory.squares))
好了,可以想象一下,的井字棋應用之前有了一個history狀態。只要在電腦走棋的時候,把這個history狀態發送給AI,AI遍歷狀態,自動找出最佳的落子點,並返回給狀態。
- 判斷哪些位置能走
function ai(arr,turnToX){
var loacation=arr.map(function(item,index){
if(item===null){
return index;
}
}).filter(function(item){
return item!==undefined;
});
...
}
通過這一步,得到一個數組。這個數組代表哪些地方(一維坐標)是可以走的。
- 確定電腦應該扮演的角色,這樣才能分析——把turnToX狀態傳進來
var player=turnToX?'X':'O';
- 計算能走的點位價值,sort排序返回點位。
之前所謂的ai演算法實現的大坑,其實也就是這麼回事。
三. 情景分析
到目前為止,拿到了可落子的點位,AI已經實現了一半。
再次請出這張棋盤圖:
AI就根據這張圖照著寫。因為不是二維坐標系,所以判斷時必須細心了。
AI結構後續
// ai函數
var oCalc={};
for(var i=0;i<arr.length;i++){
for(j=0;j<loacation.length;j++){
var index=loacation[j];
switch(index){
case 0:
case 2:
case 6:
case 8:
oCalc['loacation'+index]=judgeConner(index);
break;
case 1:
case 3:
case 5:
case 7:
oCalc['loacation'+index.toString()]=judgeSide(index);
break;
case 4:
oCalc['loacation'+index.toString()]=judgeCenter(index);
break;
}
}
}
角位是2,4,6,8。邊位是1,3,5,7。中心位置為4。根據不同的情況返回不同的位置到對象oCalc中。(作為測試,可以把oCalc作為ai函數的return值)。
- 角位,需要判定的有3條線路(橫豎斜)
- 邊位,需要判斷2條線路(橫豎)
- 中心位置需要判斷4條線路:(橫豎撇捺)
接下來就是在各個函數里var一個value,一個個對arr進行判定。
當然,有稍微簡化一點的辦法:意識到很多判斷是重覆的,比如說邊角判斷,線路判斷2,5,8
其實就是棋盤被“推倒之後的”0,1,2
。根據這個思想可以定義a,b兩個值,當判斷的邊角是0時,a=1,b=2。當判斷的邊角是2時,a=5,b=8。同理,當判斷的邊角是8時,a=7,b=6...如此往複。
處理相同權重的點位
游戲一開始,就發現所有的位置打出來,權重都是0。這也就是之前把單條線路設置為0的坑。最合理的演算法應該是設置為1或10。也就是說,中心位置按理是最佳落子點,但是由於判斷太繁瑣,就省略了這些判斷,當成是0。
事實上做這些判斷是得不償失的,因為體現“1”這個價值的點位只有在第一步時才用到。
解決方案:就是在judgeCneter函數中首先來一個判斷,如果可落子點為9,直接把value設置為10並馬上返回。
頁面還可能會有很多相同價值的落子點,找出第一個返回就行了。
排序
當前需要根據oCalc的鍵值來獲取該對象的屬性:
var largest={
index:-1,
value:-100
};
for(var attr in oCalc){
if(oCalc[attr]>largeast.value){
largest.index=parseInt(attr.replace('loacation',''));
largest.value=oCalc[attr];
}
}
console.log([player,JSON.stringify(largest)]);
return largest.index;
/*******ai函數結束*********/
}
註意largest初始狀態。
這下就拿到點位了!
四. 回歸React
現在可以做一個按鈕,點擊請求之後,直接在棋盤上反映AI所走給出來的最佳位置。
給按鈕綁定一個handleClick事件,之前的handleClick參數傳的是狀態,,現在直接把ai的計算結果傳進去。
之前的組件已經完備,狀態工作正常。以至於不需要再動什麼內容。
效果如下
留意到最後一個狀態index是-1.再點擊就會報錯。所以handleClick開頭再加一個如果參數等於-1的判斷。
筆者之前用過jQuery寫面向過程的井字棋(http://djtao.top/ttt/),二者的代碼量差不多,但是React寫的思路非常明確,錯誤非常好排查。深感覺到工程越大,框架優勢越明顯。
至此,渡劫成功。代碼就不放了。