99% of information we read, we forget anyway. The best way to remember is to "DO".
體驗地址:http://www.hoohack.me/assets/tictactoe/
游戲完整的代碼在我的 github 上,有興趣也可以圍觀一下:TicTacToe,也希望大家可以點個 star。
緣起
最近在FreeCodeCamp上面學習前端知識,不知不覺已經學到了319課,現在遇到的一個小project是做一款井字游戲。說起井字游戲,真是滿滿的童年味道,還記得最瘋狂的時候是小時候跟同桌拿着一張草稿紙就能玩一節課,回到家跟弟弟也能繼續玩,對於沒有太多娛樂節目的童年來說,真是一款玩不厭的小游戲。這款游戲代碼比較簡單,主要是掌握算法的原理,但是也有一些需要注意的地方,於是想把自己遇到的問題記錄下來。
游戲界面
進入正題。項目的效果圖如下:
FreeCodeCamp上要求不能查看源碼來實現,於是便想着先把頁面做出來。看到井字格子,就想着用9個li,然后設置li的邊框作為井字線。於是用了一個div包住一個ul,里面有9個li。
游戲有一個開始界面可供選擇玩家的角色,然后選擇先手是哪一方,接着開始游戲。選擇界面做了一個遮罩層,里面提供給用戶選擇,選擇之后便把遮罩層隱藏並開始游戲。
井字游戲算法
算法參考了這篇文章。但里面的圖片看不到了,筆者根據自己的理解再解釋一遍,並配上一些圖片。
這次做的是人機對戰,因此就需要寫出比較智能的算法。首先,設計者要懂得玩游戲,有自己的策略,接下來就是將自己的策略付諸實現。
從下圖可以看到,整個棋盤可以連接處8條線,即一共有8種取勝可能:
在下棋過程中,一共有下面幾種狀態:
1、開局第一步
2、第二步
3、攻擊
4、防守
5、垃圾時間
1、開局第一步,這一步有兩種情況
A、如果先手是電腦,那么就將棋子下在中心位置,如圖:
B、如果先手是玩家,那么有下面三種情況要考慮
如果玩家在中心位置,那么電腦必須落在四個角位,因為如果不落在角位,那么就會出現必輸的情形。假設現在用1-9表示9個棋位。如下圖所示,如果玩家第一步在中心位置,第二步電腦落在第2位的棱位(圖中的2),第三步玩家只需要在第7或第9位下棋(圖中的3),第四步電腦必須在1或3位,第五步玩家跟進在7或9,則第六步電腦必須在1或3,那么第七步玩家只需要在4或者6下棋就可以贏了。
如果玩家在棱位/角位,那么電腦需要在中心位置下棋,在保證不輸的情況下反擊。
2、第二步棋(先角原則)
根據上面的分析,如果先手是玩家且玩家落棋在中心位置,為了避免必輸的情形,電腦需要落棋在角上。而如果先手是電腦,那么如果玩家落在棱位,電腦落在角位讓必輸的情形屬於玩家。
3、攻擊
檢測棋盤,如果有兩枚己方的棋子連在一起且連線中仍有空位,那么就落棋在該位。
4、防守
檢測棋盤,如果有對方的兩枚棋子連在一起且連線中仍有空位,那么就落棋在該位。
5、垃圾時間
當不需要攻擊也不需要防守的時候,那就隨便找個位置下棋,盡可能找到連線中還有兩個空位的位置。
特殊情況
有一種特殊情況是不能執行先角原則的,如下圖所示,第一步,玩家先下棋在1,第二步,電腦根據開局第一步的規則下棋在中心位置5,第三步,玩家在1的對角位置9下棋,根據先角原則,第四步電腦將落在3或者7的棋位,第五步玩家在7或者3的位置封堵電腦,那么此時電腦就輸了。唯有此種情況不能執行先角原則,所以在非攻擊且非防守的時候要先排除掉此情況。
具體實現
說了那么多,可能比較枯燥,下面介紹一下具體的代碼實現。
使用一個二維數組panel保存棋盤的狀態,1是電腦的值,-1是玩家的值。
winArr保存所有可能贏的8個棋位組合;維護computerWin和userWin,初始值等於winArr,當電腦或玩家每次下棋時,都分別更新這兩個數組,刪除掉不能贏的棋位組合。在更新panel的時候會分別更新computerWin和userWin。
核心的方法是play,play的執行步驟偽代碼如下:
如果可以攻擊
遍歷computerWin數組,找到可以攻擊的棋位,下棋,顯示是否贏了。
不能攻擊,如果需要防守
遍歷userWin,根據玩家可贏的組合,找出需要防守的棋位,下棋,更新panel;
不需要防守,如果是電腦先手的第一步
在中心位置下棋,更新panel;
不是先手第一步
如果中心位置沒有被占去,在中心位置下棋,更新panel;返回
如果是特殊情況,在棱位下棋,更新panel; 返回
如果角位仍有位置,選擇一個角位下棋,更新panel; 返回
最后一種情況,找到剩余的空位,優先選擇位於computerWin的空位,下棋,更新panel; 返回
play算法的實現如下:
if(canAttack()) { console.log("attack"); var attackPos = findAttackPos(); updatePanel(attackPos, computerVal); } else if(needDefend()) { console.log("defend"); var defendPos = findDefendPos(); updatePanel(defendPos, computerVal); } else if(firstStep()) { console.log("first"); updatePanel(firstPos, computerVal); running = true; } else { console.log("other"); if(panel[1][1] == 0) { updatePanel(firstPos, computerVal); return; } if(special()) { console.log('special'); var pos = findSpecialPos(); updatePanel(pos, computerVal); return; } var random = Math.floor(Math.random() * 2); if(panel[0][0] == 0 && panel[2][2] == 0) { var pos = (random == 0) ? 0 : 8; updatePanel(pos, computerVal); } else if(panel[0][2] == 0 && panel[2][0] == 0) { var pos = (random == 0) ? 2: 6; updatePanel(pos, computerVal); } else { var otherPos = findEmptyPos(); updatePanel(otherPos, computerVal); } }
總結
在編碼的過程中遇到的一個難題就是JavaScript的數組對象,我在第一次調用play方法開頭輸出panel的時候,得到的是play執行后panel的值,后來請教一位大神,發現是因為panel是一個對象,因為對象遍歷引用的都是同一塊內存地址,所以一旦有改變,就全部改了。如果直接使用下標輸出每一個值的話是可以得到初始的值的,也可以用JSON方法將數組字符串,然后打印出來查看結果。
另外,也學會了如何在JavaScript里面封裝一個類,將私有方法寫在類的外面,需要暴露的方法寫在類里面。當然,還有很多需要學習的地方。繼續學習。
有時候一些東西看起來很簡單,或者聽到了很多次,心里面覺得實現起來應該很簡單的,沒什么了不起,覺得不以為然,但只有真正去實踐出來的時候才能體會到其中的樂趣和思想,才能真正的掌握。所以,盡情的去DO。
本文較短,如果還有什么疑問或者建議,可以多多交流,原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。
如果本文對你有幫助,望大力點推薦。