# 五子棋 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>
);
```