用 JavaScript 實現一個 TicTacToe 遊戲
這裡我們給大家講講一個好玩的程式設計練習,很多同學想到程式設計練習就會覺得與演算法有關。但是往往在程式設計的過程中,我們要實現某種邏輯或者是功能的時候,確實是需要用到演算法。但是我覺得 Winter 老師說的也挺對的。
程式設計練習有一部分是與演算法和資料結構密切相關的,但是也有一部分是跟語言比較相關的。我們既要知道這個演算法我們怎麼去寫,我們還要跟語言相結合,就是怎麼去用我們的語言更好的去表達。不過程式設計練習的核心還是提升我們程式設計的能力。
TicTacToe 是一個非常著名的一個小遊戲,國外叫做 TicTacToe,國內我們叫它 “三子棋” 或者 “一條龍”。
如果我們要實現這個小遊戲,我們首先就需要了解這個遊戲的規則。如果不懂這個遊戲的規則,我們是無法用程式碼語言來表達的。
「一」規則
- 棋盤:3 x 3 方格
- 雙方分別持有 ⭕️ 和 ❌ 兩種棋子
- 雙方交替落子
- 率先連成三子直線的一方獲勝
- 這個直線分別可以是“橫”,“豎”,“斜” 三種
「二」程式碼實現
「1」建立棋盤
這個遊戲是基於擁有一個可以放棋子的棋盤,換做我們的程式的話,就是一個存放資料的地方,記錄著每個棋子所放在的位置。
這裡我們可以用一個二維數字來存放:
[plain]view plaincopy- letparttern=[
- [2,0,0],
- [0,1,0],
- [0,0,0]
- ]
- console.log(pattern)
0
表示為沒有棋子存放在這個棋盤的位置1
表示為在其面上有 ⭕️ 的棋子2
表示為在其面上有 ❌ 的棋子
我們擁有棋盤的資料之後,因為這是一個可以給使用者玩的遊戲,我們當然需要展示在瀏覽器上的。所以這裡我們就需要加入 HTML 和 CSS。
[plain]view plaincopy- <style>
- *{
- box-sizing:border-box;
- background:#0f0e18;
- }
- .container{
- margin:3rem;
- display:flex;
- 20000
- justify-content:center;
- align-items:center;
- flex-direction:column;
- }
- h1{
- color:#ea4dc5;
- }
- #board{
- width:300px;
- height:300px;
- display:flex;
- flex-wrap:wrap;
- }
- .cell{
- width:100px;
- height:100px;
- background:#2d2f42;
- border:1pxsolid#0f0e18;
- cursor:pointer;
- display:flex;
- justify-content:center;
- align-items:center;
- transition:background500msease;
- }
- .cell:hover{
- background:#454966;
- }
- .cell.iconfont{
- background:transparent;
- width:100%;
- height:100%;
- font-size:50px;
- line-height:100px;
- text-align:center;
- vertical-align:middle;
- }
- .blue{
- color:#7ceefc;
- }
- .purple{
- color:#ea4dc5;
- }
- #tipsp{
- color:#dddddd;
- }
- </style>
- <divclass="container">
- <h1>Tic-tac-toeSimulator</h1><!--標題-->
- <divid="board"></div><!--棋盤-->
- <divid="tips"></div><!--這裡是用於提示的,後面的功能會用到-->
- </div>
寫好了上邊的 HTML 和 CSS,我們會發現棋盤上是一個空
div
,棋盤上的格子還沒有被加上。
這裡我們是需要根據我們的
pattern
中的資料來建立棋盤的。所以我們需要加入JavaScript
,根據我們的棋盤資料來建立我們棋盤上的格子和棋子。
- //棋盤
- letpattern=[
- [2,0,0],
- [0,1,0],
- [0,0,0]
- ];
- letchess=1;
- /**渲染棋盤*/
- functionbuild(){
- //獲取棋盤元素
- letboard=document.getElementById('board');
- board.innerHTML='';
- //填充棋盤
- for(lety=0;y<3;y++){
- for(letx=0;x<3;x++){
- letcell=document.createElement('div');
- cell.classList.add('cell');
- //建立圓圈棋子
- letcircle=document.createElement('i');
- circle.classList.add('iconfont','icon-circle','blue');
- //建立叉叉棋子
- letcross=document.createElement('i');
- cross.classList.add('iconfont','icon-cross','purple');
- //建立空棋子
- letempty=document.createElement('i');
- letchessIcon=pattern[y][x]==2?cross:pattern[y][x]==1?circle:empty;
- cell.appendChild(chessIcon);
- board.appendChild(cell);
- }
- }
- }
建立這個棋盤我們使用了以下思路:
- 首先迴圈一遍我們的二維陣列
pattern
- 一個雙迴圈就等同於我們從上到下,從左到右的走了一篇這個棋盤資料了
- 在迴圈這個棋盤的同時我們需要把棋子也同時放入棋盤中
- 首先我們建立一個棋盤格子
div
元素,給予它一個 class 名為cell
- 如果我們遇到
1
的時候就放入 ⭕️ 到cell
裡面 - 如果我們遇到
2
的時候就放入 ❌ 到cell
裡面 - 如果我們遇到
0
的時候就放入一個 “空” 到cell
裡面 - 棋子這裡我給了一個
i
元素,並且它的 class 用了 iconfont - 當然如果我們也是可以用
emoji
替代這部分內容,直接給cell
元素加入文字 (例如:cell.innerText = '⭕️'
) - 最後把
cell
加入到棋盤board
裡面即可
這裡的程式碼我使用了 “阿里巴巴” 的
iconfont
,當然我們也可以直接用emoji
。跟著我的文章練習的同學,也可以使用我在用的iconfont
。這裡我附上我在使用的 iconfont 地址:
<link rel="stylesheet" href="//at.alicdn.com/t/font_2079768_2oql7pr49rm.css" />
最後顯示出來的就是這樣的效果:
「2」落棋子
我們已經擁有一個 3 x 3 的棋盤了,下來就是實現落棋子的動作的方法。我們想要達到的效果就是讓使用者點選一個格子的時候,就把棋子落到對應點選的位子。如果該位置已經有棋子了就不生效。
[plain]view plaincopy- /**
- *把棋子放入棋盤
- *
- *-先把當前棋子代號給予當前x,y位置的元素
- *
- *@param{Number}xx軸
- *@param{Number}yy軸
- */
- functionmove(x,y){
- if(pattern[y][x])return;
- pattern[y][x]=chess;
- chess=3-chess;
- build();
- }
這段程式碼的邏輯很簡單:
- 如果當前
x
,y
位置已經有棋子,那必然就不是 0 ,如果是 0 就直接返回,推出此方法即可 - 如果可以落下棋子,就給當前位置賦予棋子的程式碼
1
就是 ⭕️,2
就是 ❌ - 這裡我們使用了 1 和 2 的對等特性, 3 − 1 = 2 3-1=2 3−1=2,同樣 3 − 2 = 1 3-2=1 3−2=1 ,用這樣的對等換算我們就可以反正當前棋子了
- 也就是說上一位玩家的棋子是
1
, 3 − 當 前 棋 子 = 下 一 位 玩 家 的 棋 子 3-當前棋子=下一位玩家的棋子 3−當前棋子=下一位玩家的棋子 ,那就是2
- 最後呼叫我們的棋盤構建方法
build
重新構建棋盤即可
這個方法寫了,但是我們發現我們根本沒有呼叫到它,所以在棋盤上點選的時候是無任何效果的。
所以這裡我們要在構建棋盤的時候,就給每一個格子加上一個 “點選 (click)” 事件的監聽。
[plain]view plaincopy- /**渲染棋盤*/
- functionbuild(){
- //...省略了這部分程式碼
- letchessIcon=pattern[y][x]==2?cross:pattern[y][x]==1?circle:empty;
- cell.appendChild(chessIcon);
- cell.addEventListener('click',()=>move(x,y));//《==這裡加入監聽事件
- board.appendChild(cell);
- //...省略了這部分程式碼
- }
這樣我們的棋盤就可以點選格子放下棋子了!
「3」判斷輸贏
我們的遊戲到這裡已經可以開始玩了,但是一個遊戲不能沒有結局吧,所以我們還需要讓它可以判斷輸贏。
在瞭解 TicTacToe 這個遊戲的時候,我們知道這個遊戲是有幾個條件可以勝利的,就是一方的棋子在“
橫
”,“豎
”,“斜
”連成一線就可以贏得遊戲。所以這裡我們就需要分別檢測這三種情況。
通過分析我們就有 4 種情況:
- 豎行有 3 個棋子都是一樣的
- 橫行有 3 個棋子都是一樣的
- 正斜行 “
/
” 有 3 個棋子都是一樣的 - 反斜行 “
\
” 有 3 個棋子都是一樣的
那麼我們就寫一個
check()
方法來檢測:
- /**
- *檢查棋盤中的所有棋子
- *
- *-找出是否已經有棋子獲勝了
- *-有三個棋子連成一線就屬於贏了
- *
- *@param{Array}pattern棋盤資料
- *@param{Number}chess棋子代號
- *@return{Boolean}
- */
- functioncheck(pattern,chess){
- //首先檢查所有橫行
- for(leti=0;i<3;i++){
- letwin=true;
- for(letj=0;j<3;j++){
- if(pattern<i>[j]!==chess)win=false;
- }
- if(win)returntrue;
- }
- //檢查豎行
- for(leti=0;i<3;i++){
- letwin=true;
- for(letj=0;j<3;j++){
- if(pattern[j][i]!==chess)win=false;
- }
- if(win)returntrue;
- }
- //檢查交叉行
- //這裡用花括號"{}"可以讓win變數
- //變成獨立作用域的變數,不受外面的
- //win變數影響
- //"反斜行\檢測"
- {
- letwin=true;
- for(letj=0;j<3;j++){
- if(pattern[j][j]!==chess)win=false;
- }
- if(win)returntrue;
- }
- //"正斜行/檢測"
- {
- letwin=true;
- for(letj=0;j<3;j++){
- if(pattern[j][2-j]!==chess)win=false;
- }
- if(win)returntrue;
- }
- returnfalse;
- }</i>
有了這個檢測輸贏的方法,我們就可以把它放到一個地方讓它檢測遊戲的贏家了。
我們可以把這個檢測放入使用者落棋子的時候,在棋子型別反轉和重建之前,就檢測當前玩家是否勝利了。
[plain]view plaincopy- /**全域性變數——是否有贏家了*/
- lethasWinner=false
- /**
- *把棋子放入棋盤
- *
- *-先把當前棋子代號給予當前x,y位置的元素
- *-檢測是否有棋子已經贏了
- *
- *@param{Number}xx軸
- *@param{Number}yy軸
- */
- functionmove(x,y){
- if(hasWinner||pattern[y][x])return;
- pattern[y][x]=chess;
- //這裡加入了勝負判斷
- if(check(pattern,chess);){
- tips(chess==2?'❌isthewinner!':'⭕️isthewinner!');
- }
- chess=3-chess;
- build();
- }
這裡我們需要加入一個
hasWinner
的全域性變數,這個是用來記錄這個遊戲是否已經有贏家了,如果有贏家,就不能讓使用者在落棋子了。所以在move
方法的開頭就判斷了,如果有贏家了就直接返回,退出方法。
加入這段程式碼我們就可以判斷勝負的,但是我們還需要在頁面上提示使用者到底是誰贏了才完美嘛。所以這裡我們加入了一個提示插入的方法:
[plain]view plaincopy- /**
- *插入提示
- *@param{String}message提示文案
- */
- functiontips(message){
- lettips=document.getElementById('tips');
- tips.innerHTML='';
- lettext=document.createElement('p');
- text.innerText=message;
- tips.appendChild(text);
- }
最終的效果如下:
「三」實現 AI
現在我們已經擁有了一個可以玩的 “TicTacToe” 遊戲了。但是在這個時代,沒有一點 AI 支援的程式,怎麼能成為一個好的產品呢?所以這裡我們來一起給我們的遊戲加入一下 AI 的功能。
「1」預判下一步是否會贏
我們首先整理一下這個需求,在某一個玩家落棋之後,就可以檢測這盤棋的下一個玩家是否即將會贏。
要判斷下一個玩家是否即將會贏,我們就需要模擬下一個玩家落棋子的位置,其實對我們的程式來說,就是把棋子依次放入現在棋盤中空出來的格子,然後判斷下一個玩家會不會贏了遊戲。
實現思路:
- 我們的時機是在上一個玩家落下棋子後,開始模擬下一個玩家所有可能走的位置
- 這個時候我們可以迴圈現在的棋盤上的格子,模擬下一個玩家把棋子放入每一個非空的格子的結果
- 如果遇到有一個格子放入棋子後會贏的話,那下一個玩家就是可以贏了!
這裡我們要注意的是,我們需要模擬下一個玩家在當前局面下走了每一個空格子的結果,這個時候如果我們用原來的
pattern
資料來模擬,就會影響了現在遊戲裡棋子的位置。所以我們需要不停的克隆現在棋盤的資料來模擬。這樣才不會影響當前棋盤的資料。
實現預測方法:
willWin()
- /**
- *檢測當前棋子是否要贏了
- *
- *-迴圈整個棋盤
- *-跳過所有已經有棋子的格子
- *-克隆棋盤資料(因為我們要讓下一個棋子都走一遍所有空位的地方
- *看看會不會贏,如果直接在原來的棋盤上模擬,就會弄髒了資料)
- *-讓當前棋子模擬走一下當前迴圈到的空位子
- *-然後檢測是否會贏了
- *
- *@param{Array}pattern棋盤資料
- *@param{Number}chess棋子代號
- *@return{boolean}
- */
- functionwillWin(pattern,chess){
- for(leti=0;i<3;i++){
- for(letj=0;j<3;j++){
- if(pattern[i][j])continue;
- lettmp=clone(pattern);
- tmp[i][j]=chess;
- if(check(tmp,chess)){
- returntrue;
- }
- }
- }
- returnfalse;
- }
克隆方法:
clone()
- /**
- *克隆棋盤資料
- *@param{Array}pattern棋盤資料
- *@return{Array}克隆出來的棋盤資料
- */
- functionclone(pattern){
- returnJSON.parse(JSON.stringify(pattern));
- }
最後我們需要在上一個玩家落棋,之後加入輸贏預判方法:“改裝我們的
move()
方法即可”
- /**
- *把棋子放入棋盤
- *
- *-先把當前棋子代號給予當前x,y位置的元素
- *-檢測是否有棋子已經贏了
- *-反轉上一個棋子的代號,並且重新渲染棋盤
- *
- *@param{Number}xx軸
- *@param{Number}yy軸
- */
- functionmove(x,y){
- if(hasWinner||pattern[y][x])return;
- pattern[y][x]=chess;
- hasWinner=check(pattern,chess);
- if(hasWinner){
- tips(chess==2?'❌isthewinner!':'⭕️isthewinner!');
- }
- chess=3-chess;
- build();
- if(hasWinner)return;
- //這裡加入了輸贏預判
- if(willWin(pattern,chess)){
- tips(chess==2?'❌isgoingtowin!':'⭕️isgoingtowin!');
- }
- }
這裡還加入了一個判斷:
if(hasWinner) return;
,這個是為了如果這步棋有玩家已經贏了,我們就不需要再預判輸贏了,可以直接返回了。
就這樣我們就實現了一個,智慧的輸贏預判功能了,最後的效果如下圖:
「2」預判遊戲勝負
上面我們實現的 AI 只能給我們預判下一步棋是否會贏。但是並沒有給我們預判出,以現在的局面最終誰會贏。
這裡我們一起來實現一個更加智慧的 AI,讓程式在每一個玩家落子之後,判斷以現在棋子的局面,最終誰會贏,或者是否結果是和棋。
實現思路:
- 首先我們要給我們遊戲的最終結果定義好標識
- 結果是
-1
就是最後會輸 - 結果是
0
就是最後會和 - 結果是
1
就是最後會贏 - 這裡勝負是正負相反的,這個設計就是為了讓我們更好的判斷輸贏
- 也可以這麼理解,對方的棋子放入了可以贏的位置,那麼我們的結果就肯定是輸,這個結果就是剛好相反的,所以我們用了正負的標識來表達就非常方便我們用程式來判斷
- 使用我們上面說到的邏輯,我們就可以鎖定一個思路,如果我們找到對方要輸的棋子的位置,那我們就是會贏的位置,如果我們找到對方要贏的位置,我們就要輸
- 利用這樣的邏輯我們可以用一個遞迴的方法來迴圈模擬兩個玩家的落子動作,並且判斷出落棋後的結果,一直深度搜索直到我們找到一個贏家
- 這個遞迴最終會模擬兩個玩家走了這盤棋的所有情況並且找到一個能贏的局面,就可以結束迴圈了。這個也叫做“勝負節支”。贏已經是最好的結果了,我們並不需要繼續模擬到所有的情況,我們已經找到最佳的情況了。
- 當然在其他棋盤遊戲中,可能有很多勝利的局面,有可能是贏了但是損失了很多,也有贏了但是又快又減少了損失。但是在這個 “TicTacToe” 當中就不需要考慮這些因素了。
說了那麼多,我們來看看程式碼是怎麼實現的,我們先來實現一個尋找最佳結果的方法
bestChoice
:
- /**
- *找到最佳結果
- *
- *-結果是-1就是最後會輸
- *-結果是1就是最後會贏
- *-結果是0就是最後會和
- *
- *@param{Array}pattern棋盤資料
- *@param{Number}chess棋子代號
- */
- functionbestChoice(pattern,chess){
- //定義可以贏的位置
- letpoint;
- //如果當前局面,我們已經即將要贏了
- //我們就可以直接返回結果了
- if((point=willWin(pattern,chess))){
- return{
- point:point,
- result:1,
- };
- }
- //定義一個結果,-2是要比-1,0,1要小
- //所以是一個最差的局面,我們需要從最差的局面開始
- //數字變得越高,我們就越接近贏
- letresult=-2;
- point=null;
- outer:for(lety=0;y<3;y++){
- for(letx=0;x<3;x++){
- //跳過所有已經有棋子的地方(因為不能在這些地方放我們的棋子了)
- if(pattern[y][x])continue;
- //先克隆當前棋盤資料來做預測
- lettmp=clone(pattern);
- //模擬我們的棋子下了這個位置
- tmp[y][x]=chess;
- //找到我們下了這個棋子之後對手的最佳結果
- letopp=bestChoice(tmp,3-chess);
- //記錄最佳結果
- if(-opp.result>=result){
- result=-opp.result;
- point=[x,y];
- }
- if(result===1)breakouter;
- }
- }
這段程式碼做了什麼?其實就是讓我們的程式進行了自我博弈,A 方找到自己可以贏的落子位置,然後 B 方找自己可以贏的落子位置,知道最後進入一個結果,要不兩方都贏不了,那就是和局,要不就是一方獲勝為止。
我們會關注到,這裡
bestChoice
返回了一個物件,一個屬性是result
, 這個就是預判出來這個遊戲最後的結果。而另外一個是point
,這個就是當前玩家可以走的位置,也是可以達到最佳結果的位置。這個在我們實現最後一個 AI 功能的時候會用到。這一步我們只需要用到result
屬性來做判斷,輸出勝負提示即可。
有了這個更高階的預判 AI,我們就可以把我們的
willWin()
替換下來了。
這裡我們改造一下我們的
move()
方法:
- /**
- *把棋子放入棋盤
- *
- *-先把當前棋子代號給予當前x,y位置的元素
- *-檢測是否有棋子已經贏了
- *-反轉上一個棋子的代號,並且重新渲染棋盤
- *
- *@param{Number}xx軸
- *@param{Number}yy軸
- */
- functionuserMove(x,y){
- if(hasWinner||pattern[y][x])return;
- pattern[y][x]=chess;
- if((hasWinner=check(pattern,chess))){
- tips(chess==2?'❌isthewinner!':'⭕️isthewinner!');
- }
- chess=3-chess;
- build();
- if(hasWinner)return;
- letresult=bestChoice(pattern,chess).result;
- letchessMark=chess==2?'❌':'⭕️';
- tips(
- result==-1
- ?`${chessMark}isgoingtoloss!`
- :result==0
- ?`Thisgameisgoingtodraw!`
- :`${chessMark}isgoingtowin!`
- );
- }
最後出來的效果就是如此:
當然這個預判是在預判最好的結果,這裡我們假設了兩個玩家都是非常優秀的,每一步都是走了最佳的位置。但是如果玩家失誤還是有可能反敗為勝的哦!
「3」加入電腦玩家
我們前面實現的 AI,已經足夠讓我們實現一個很聰明的 AI 電腦玩家了。
在上一步我們實現了
bestChoice()
方法的時候,這個方法返回的屬性裡,有一個point
屬性,這個point
其實就是玩家最佳落子的位置,我們只需要讓程式自動落子到這個位置,我們就完成了電腦玩家的功能了。
實現思路:
- 上一個玩家落子之後,就可以呼叫我們電腦玩家落子方法
- 使用
bestChoice
找到最佳結果的落子位子 - 給最佳位子放下電腦玩家的棋子
- 最後繼續預測這個遊戲的結局
真的就是那麼簡單,我們來看看程式碼怎麼實現:
[plain]view plaincopy這裡我們需要改造
move()
方法,改為userMove()
,並且建立一個computerMove()
。
- /**
- *把棋子放入棋盤
- *
- *-先把當前棋子代號給予當前x,y位置的元素
- *-檢測是否有棋子已經贏了
- *-反轉上一個棋子的代號,並且重新渲染棋盤
- *
- *@param{Number}xx軸
- *@param{Number}yy軸
- */
- functionuserMove(x,y){
- if(hasWinner||pattern[y][x])return;
- pattern[y][x]=chess;
- if((hasWinner=check(pattern,chess))){
- tips(chess==2?'❌isthewinner!':'⭕️isthewinner!');
- }
- chess=3-chess;
- build();
- if(hasWinner)return;
- computerMove();
- }
- /**電腦自動走棋子*/
- functioncomputerMove(){
- letchoice=bestChoice(pattern,chess);
- if(choice.point)pattern[choice.point[1]][choice.point[0]]=chess;
- if((hasWinner=check(pattern,chess))){
- tips(chess==2?'❌isthewinner!':'⭕️isthewinner!');
- }
- chess=3-chess;
- build();
- if(hasWinner)return;
- letresult=bestChoice(pattern,chess).result;
- letchessMark=chess==2?'❌':'⭕️';
- tips(
- result==-1
- ?`${chessMark}isgoingtoloss!`
- :result==0
- ?`Thisgameisgoingtodraw!`
- :`${chessMark}isgoingtowin!`
- );
- }
就是這樣我們就實現了電腦玩家,這樣一個單身狗也可以玩 “TicTacToe” 了。