# 五子棋 Gobang ## 前期規劃 在看完官方教學後,其實大部分都可以續用在五子棋上面,只差在難度較高的勝負邏輯判斷。 ( 所以這篇筆記只是單純紀錄勝負判斷的思考過程 ) #### ☞ Component 如何分 在正式開始寫之前,經過思考後,切 Component 的方式跟 state 儲存位置應該可以照 React 的五子棋教學: - `Game` 整個遊戲 - `Board` 棋盤 - `Square` 格子 - `History` 側欄( 放步驟記錄 ) #### ☞ State 資料格式 比較棘手的是牌面陣列 `squares` 要怎麼規劃: ```javascript // 儲存在最上層的 Component:Game state = { history: [ { squares: Array(19).fill(null) } ], blackIsNext: true, // => 黑棋先下 stepNumber: 0 // => 目前步數 } ``` --- ### 要用哪種形式儲存棋面 squares? 毫無頭緒,不如先畫出 `19x19` 的棋面,把座標印在上面。 ```javascript createArr = (num) => { let arr = []; for (let i = 0; i <= num; i += 1) { for (let j = 0; j <= num; j += 1) { arr.push([i, j]); } } return arr; } // 先試著把座標存在 state this.state = { history: [ { squares: this.createArr(19) } ], xIsNext: true, stepNumber: 0 }; ``` 歷經一番波折印出: ![螢幕快照 2019-09-16 下午6.19.27](https://i.imgur.com/t2OvXV8.jpg) 發現這樣好像就滿 OK 的,但每個 square 應該都要有以下資訊: - `position` :`[x, y]` - `value` : `null || black || white` #### 產生棋面陣列 所以把剛剛產生陣列的函式 `createArr` 改寫,幫每個 square 加上屬性: ```javascript createArr = (num) => { let arr = []; for (let i = 0; i <= num; i += 1) { for (let j = 0; j <= num; j += 1) { arr.push({ value: null, position: [i, j] }); } } return arr; } ``` --- ### 點擊下棋 接下來就好處理了,把 UI 的 `position` 換成 `value`,加上 `handleClick()` 將 square 的 value 改掉 --- ## 勝負邏輯判定 #### 沒有頭緒,決定先考慮橫排就好 因為目前 state 的資料格式無法查找座標,只能找 `squares[index]`,不能用 `squares[x][y]` - 或者可以相反? 用 `[x, y]` 去推導出 `index` ? - 例如:`[0][0]` => `0` , `[0][1]` => `1`, `[1],[0]` => `20` - 那就寫個函式可以互相轉換 `toArr()`, `toIndex()` while ( `credit >=4` || `end >= 2` ) - 1. 往 x 軸上找 - 有: `credit++` - 繼續往 x 軸上找 - 有: `credit++` - 繼續往 x 軸上找 - 有: `credit++` - 繼續往 x 軸上找 - 有: `credit++` => `勝利` - 沒有: `end++` => 跳到步驟 2 - 沒有: `end++` => 跳到步驟 2 - 沒有: `end++` => 跳到步驟 2 - 沒有: `end++` => 跳到步驟 2 - 沒有: `end++` - 2. 往 x 軸下找 - 有: `credit++` - .... - 沒有: `end++` => `結束` - 沒有: `end++` => `結束` --- ## 往橫向找 總之寫想半天,把往左右 ( 橫向 ) 的方向判定完畢: #### 一但碰到不符合的 value 就往反方向找:`findRow()` 跑 while 迴圈,直到 `credit >= 4` 或 `end >=2` 就跳出: - `(1)` - 往 target 右邊找 - 是 => `credit++` => 繼續往右找 - 是 => `credit++` => 繼續往右找 - 不是 => `end++` => 跳到步驟 `2` - `(2)` - 不是 => `end++` => 換往左找 - 是 => `credit++` => 繼續往左找 - 不是 => `end++` => **結束** 簡單來說就是: - 一樣 value => `credit++` - 不一樣 => `end++`( 圖片上的藍、綠色塊 ) ![螢幕快照 2019-09-17 下午2.06.10](https://i.imgur.com/MIlOJcE.jpg) #### 完整程式碼如下 ```javascript console.log('==== 棋子:', squares[i].value, squares[i].position, ' ===='); // 計算步數 & 方向 function countStep(n, init) { if (n === 0) { n = init ? 1 : -1; init = init ? !init : init; // => 一旦初始化就設成 false } else { n = n > 0 ? n + 1 : n - 1; } console.log(n >= 0 ? '往右找: ' : '往左找: ', n); return [n, init]; } // 往橫向找 function findRow() { let n = 0; let credit = 0; let end = 0; let init = true; while (end < 2 && credit < 5) { [n, init] = countStep(n, init); if (squares[i + n] && squares[i].value === squares[i + n].value) { credit++; } else { end++; console.log('碰到牆: ', squares[i+n].position); } // 碰到牆往反方向找 if (end >= 1 && n >= 1) n = 0; } console.log('==> 跳出'); return credit; } // 檢查結果 function checkResult(credit) { if (credit < 4) return null; if (squares[i].value === 'X') { console.log('《 黑贏了 》'); } else { console.log('《 白贏了 》'); } }; checkResult(findRow()); ``` --- ### 接著把橫向判斷改成直向 直覺應該不會太難,因為我的棋面的陣列只有一維,所以往左右找只要 `+1`、`-1` 比較簡單,改成垂直查找只要再乘上 `20` 就好: - 往右找: `+1` - 往左找: `-1` - 往上找: `-20` - 往下找: `+20` 把 findRow 變成可以用參數改變方向: ```javascript checkResult(findStraight('row')); // => 往左右檢查 checkResult(findStraight('column')); // => 往上下檢查 ``` ### 目前完整程式碼 其實最大的修改是計算步數的函式 `countStep`。 ( 程式碼還很亂,因為放了很多方便 debug 的程式碼,但目前算堪用 ) ```javascript console.log('==== 棋子:', squares[i].value, squares[i].position, ' ===='); // 計算步數 & 方向 function countStep(n, init, type) { if (n === 0) { if (type === 'row') n = init ? 1 : -1; if (type === 'column') n = init ? 20 : -20; init = init ? !init : init; // => 一旦初始化就設成 false return [n, init]; } if (type === 'column') { n = n > 0 ? n / 20 + 1 : n / 20 - 1; n *= 20; console.log(n > 0 ? '往下找: ' : '往上找: ', n); } else { n = n > 0 ? n + 1 : n - 1; console.log(n > 0 ? '往右找: ' : '往左找: ', n); } return [n, init]; } // 往橫、直向找 function findStraight(type) { let n = 0; let credit = 0; let end = 0; let init = true; while (end < 2 && credit < 5) { [n, init] = countStep(n, init, type); const targetSquare = squares[i + n]; if (targetSquare && squares[i].value === targetSquare.value) { credit++; } else { end++; if (targetSquare) { console.log('碰到牆: ', targetSquare && targetSquare.position); } else { console.log('碰到不在目標上的牆'); } } // 碰到牆往反方向找 if (end >= 1 && n >= 1) n = 0; } console.log('==> 跳出,結束', type); return credit; } // 檢查結果 function checkResult(credit) { if (credit < 4) return null; if (squares[i].value === 'X') { console.log('《 黑贏了 》'); } else { console.log('《 白贏了 》'); } }; checkResult(findStraight('row')); checkResult(findStraight('column')); ``` --- ## 最後再來處理交叉斜線 處理最棘手的交叉線,先來看 `[index]` 要怎麼換算,不知道怎麼算就列出來: - 右斜線 `/` - 往右上找:`-19`、`-38`... - 往右找: `+1` + 往上找: `-20` - 往左下找:`+19`、`+38`... - 往左找: `-1` + 往下找: `+20` - 左斜線 `\` - 往左上找:`-21`、`-42`... - 往左找: `-1` + 往上找: `-20` - 往右下找:`+21`、`+42`... - 往右找: `+1` + 往下找: `+20` 寫出來感覺就不難了,規則跟剛剛的「 垂直查找 」類似,只要在剛剛的基礎再做變化就行: - 垂直線 `|` => 右斜線 `/` - 往上找: `-20` - `+1` => 變右上角 `-19` - 往下找: `+20` - `-1` => 變左下角 `+19` - 垂直線 `|` => 左斜線 `\` - 往上找: `-20` - `-1` => 變左上角 `-21` - 往下找: `+20` - `+1` => 變右下角 `+21` 我解題的想法是,問題越簡單越好,所以先土法煉鋼的寫完兩條斜線,一樣是改 `countStep()` 函式 ```javascript function countStep(n, init, type) { if (n === 0) { if (type === 'row') n = init ? 1 : -1; if (type === 'column') n = init ? 20 : -20; if (type === 'slash') n = init ? 19 : -19; if (type === 'backslash') n = init ? 21 : -21; init = init ? !init : init; // => 一旦初始化就設成 false return [n, init]; } if (type === 'column') { n = n > 0 ? n + 20 : n - 20; console.log(n > 0 ? '往下找: ' : '往上找: ', n); } else if (type === 'row') { n = n > 0 ? n + 1 : n - 1; console.log(n > 0 ? '往右找: ' : '往左找: ', n); } else if (type === 'slash') { n = n > 0 ? n + 19 : n - 19; console.log(n > 0 ? '往右下找: ' : '往左上找: ', n); } else if (type === 'backslash') { n = n > 0 ? n + 21 : n - 21; console.log(n > 0 ? '往右下找: ' : '往左上找: ', n); } return [n, init]; } // 調用時 checkResult(findStraight('row')); checkResult(findStraight('column')); checkResult(findStraight('slash')); checkResult(findStraight('backslash')); ``` **驚人發現: 寫完就發現規則都一樣,其實只要改初始值就好!** - 寫一個 `getStartPosition()` 把各方向的初始值定義好: ```javascript function getStartPosition(type) { switch (type) { case 'row': return [1, -1]; case 'column': return [20, -20]; case 'slash': return [19, -19]; case 'backslash': return [21, -21]; } } ``` - 修改土炮 `countStep()` 計步函式,整個簡潔許多: ```javascript function countStep(n, init, type) { const [startForward, startBack] = getStartPosition(type); // 第一次查找,不同方向用指定初始值 if (n === 0) { n = init ? startForward : startBack; init = init ? !init : init; return [n, init]; } // 之後的查找位置,遞增或遞減初始值 n = n > 0 ? n + startForward : n + startBack; return [n, init]; } ``` --- ### 結束判定 再來就把檢查判定改過一輪就行了,本來是這樣的: ```javascript // 檢查結果 function checkResult(credit) { if (credit < 4) return null; if (squares[i].value === 'X') { console.log('《 黑贏了 》'); } else { console.log('《 白贏了 》'); } }; checkResult(findStraight('row')); checkResult(findStraight('column')); checkResult(findStraight('slash')); checkResult(findStraight('backslash')); ``` 改成某方向出現贏家就直接 `return`,不用再去比較其他方向。 ```javascript // 檢查結果 function checkResult(credit) { if (credit < 4) return null; return (target.value === 'X') ? 'X' : 'O'; }; const direction = ['row', 'column', 'slash', 'backslash']; // 從 row 開始找,有贏家就立刻回傳 const result = direction.find(type => checkResult(findStraight(type))); return result; ``` --- ## 完成勝負判斷,最後整理完的程式碼 把最難寫的勝負判斷寫完了!! 過程真是艱辛,雖然用的方法都很土炮,且還有很多優化空間,總之能完成很開心,也深深感受到寫程式要化繁為簡的必要性。 ```javascript function calculateWinner(squares, i) { const target = squares[i]; // => 初始查找位置 function getStartPosition(type) { switch (type) { case 'row': return [1, -1]; case 'column': return [20, -20]; case 'slash': return [19, -19]; case 'backslash': return [21, -21]; } } // => 計算查找步數 function countStep(n, init, type) { const [startForward, startBack] = getStartPosition(type); // 第一次查找,用初始值 if (n === 0) { n = init ? startForward : startBack; init = init ? !init : init; return [n, init]; } // 之後的查找位置,遞增或遞減初始值 n > 0 ? n += startForward : n += startBack; return [n, init]; } // => 查找該方向有多少同樣的棋子 function countCredit(type, init, n, credit, end) { while (end < 2 && credit < 5) { [n, init] = countStep(n, init, type); const targetSquare = squares[i + n]; if (targetSquare && squares[i].value === targetSquare.value) { credit++; } else { end++; } // 碰到牆就往反方向找 if (end >= 1 && n >= 1) n = 0; } return credit; } // => 檢查結果 function getResult(credit) { if (credit < 4) return null; return (target.value === 'X') ? 'X' : 'O'; }; const direction = ['row', 'column', 'slash', 'backslash']; const result = direction.find(type => ( getResult(countCredit(type, true, 0, 0, 0))) ); return result; } ``` --- ## 其他修改 #### ☞ 簡化 state 結構 寫一寫發現 state 的 squares 根本不用存位置資訊,所以把原本的物件格式改成 `value` 字串 ```javascript // 本來存在 state 的 squares 格式長這樣 squares : [ { position : [0, 0], value: 'X' }, { position : [0, 1], value: null }, ....] // 改成這樣 squares : ['X', null, null, ...] ``` #### ☞ 把寫死的資料都變成變數控制: ```javascript this.rowNum = 15; // => 幾路棋 this.rowSize = 30; // => 棋盤格尺寸 (px) ``` 控制幾路棋的參數 `this.rowNum` 滿多地方會用到就不貼了,另外一個就是用 `this.rowSize` 控制CSS,直接寫在 inline-style 上 ```javascript // => 整個盤面寬度 <div className="board-row" style={{ width: ((rowSize - 1) * rowNum) + 'px' }}> { squares.map((item, index) => { return this.renderSquare(item, index, rowSize); }) } </div> // => 棋盤格 const style = { width: size + 'px', height: size + 'px', lineHeight: size + 'px', } return ( <button className="square" onClick={onClick} style={style}> {value} </button> ); ```