想用 React 做出五子棋嗎?先從圈圈叉叉開始吧

一步一步來吧!

簡述

在做出五子棋以前,可以先從比較簡單的圈圈叉叉開始,所以這一篇是參考 React 官方教學 做的筆記,不然官方的敘述實在看得我頭很痛rrrr。

分析結構

第一步先從結構分析開始,假設我們有以下 Component:

  • Game 整個遊戲
  • Board 棋盤
  • Square 每個格子

那結構大概就會長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function Square() {
return (
<button>格子</button>
);
}

function Board() {
return (
<div>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
</div>
);
}


export default function Game() {
return <Board />;
}

這邊為了可讀性先把 props 都拿掉了,後面會在一步一步解釋。

規劃 state

在圈圈叉叉這遊戲裡面會需要:

  • 棋盤的 state
  • 輪到誰下棋的 state

所以會這樣子做:

1
2
3
4
function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
const [xIsNext, setXIsNext] = useState(true);
}

因為是九宮格,所以會用這樣的陣列來存:

1
2
3
4
5
const squares = [
null, null, null,
null, null, null,
null, null, null,
]

每當我們想改變棋盤的時候,就會像這樣:

1
2
3
4
5
6
// 第一輪
const squares = [
"O", null, null,
null, null, null,
null, null, null,
]

接著:

1
2
3
4
5
6
// 第二輪
const squares = [
"O", null, null,
null, "X", null,
null, null, null,
]

渲染出 Square

在有了這些基本規劃後,我們先來處理畫面的問題。

首先要把 squares 當作 props 給 <Square /> 用,讓它根據 state 來顯示目前棋盤的樣子。

所以 <Square /> 的部分會寫成這樣:

1
2
3
4
5
6
// 接收 props
function Square({ value }) {
return (
<button className="square">{value}</button>
);
}

<Board /> 的部分則是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
const [xIsNext, setXIsNext] = useState(true);

// 拿來渲染 Square 的函式
const renderSquare = (i) => {
return <Square value={squares[i]} />;
};

return (
<div>
<div className="status">{status}</div>
<div className="board-row">
{renderSquare(0)}
{renderSquare(1)}
{renderSquare(2)}
</div>
<div className="board-row">
{renderSquare(3)}
{renderSquare(4)}
{renderSquare(5)}
</div>
<div className="board-row">
{renderSquare(6)}
{renderSquare(7)}
{renderSquare(8)}
</div>
</div>
);
}

這邊寫成 renderSquare 是為了讓 code 比較簡潔,不然原本得這樣寫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
return (
<div>
<div className="status">{status}</div>
<div className="board-row">
{<Square value={squares[0]} />}
{<Square value={squares[1]} />}
{<Square value={squares[2]} />}
</div>
<div className="board-row">
{<Square value={squares[3]} />}
{<Square value={squares[4]} />}
{<Square value={squares[5]} />}
</div>
<div className="board-row">
{<Square value={squares[6]} />}
{<Square value={squares[7]} />}
{<Square value={squares[8]} />}
</div>
</div>
);

做到這一步以後,就能確保 <Square /> 會根據 props 來顯示值,可以試著先把 useState 的初始值設為 "A",就會看到這個結果:

這邊沒問題的話就可以進到下個步驟了。

更新棋盤的 State

接著,我們希望當 <Square /> 的按鈕 click 時去修改棋盤的 state,讓它顯示 O 或 X,也就是說 state 必須變成這樣:

1
2
3
4
5
const newSquares = [
null, null, null,
null, null, null,
null, null, "O",
]

不過我們都知道子層沒辦法直接去改放在父層的 state,因此這邊一樣要透過 props 來傳入修改 state 的 function 進去。

這邊先在 <Board /> 寫好用來變更 state 的 function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
const [xIsNext, setXIsNext] = useState(true);

// 預期會傳入一個 i 來告知我們要更新哪一筆值
const handleChangeSquaresState = (i) => {
// 複製一份原本的
const newSquares = squares.slice();
// 把要更新的地方賦值
newSquares[i] = "O";
// 傳給 setter
setSquares(newSquares);
};

// ...
}

如果你偏好 map 的話也可以這樣寫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
const [xIsNext, setXIsNext] = useState(true);

const handleChangeSquaresState = (i) => {
setSquares(
squares.map((item, index) => {
if (i === index) return "O";
return item;
})
);
};

// ...
}

接著就可以把寫好的東西傳給 <Square /> 了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
const [xIsNext, setXIsNext] = useState(true);

const handleChangeSquaresState = (i) => {
const newSquares = squares.slice();
newSquares[i] = "O";
setSquares(newSquares);
};

const renderSquare = (i) => {
return (
<Square
value={squares[i]}
// 傳進去
handleChangeSquaresState={() => handleChangeSquaresState(i)}
/>
);
};

// ...
}

這邊是讓我卡比較久的地方,因為有用到「Clousre」的概念,所以這邊多做個解釋。

首先,我們在渲染 <Square /> 時是這樣做的:

1
2
3
4
5
6
7
8
9
10
11
return (
{renderSquare(0)}
{renderSquare(1)}
{renderSquare(2)}
{renderSquare(3)}
{renderSquare(4)}
{renderSquare(5)}
{renderSquare(6)}
{renderSquare(7)}
{renderSquare(8)}
);

所以經過 renderSquare 處理後就等於:

1
2
3
4
5
6
7
8
9
10
11
12
13
<Square
value={squares[0]}
// 傳進去
handleChangeSquaresState={() => handleChangeSquaresState(0)}
/>

<Square
value={squares[1]}
// 傳進去
handleChangeSquaresState={() => handleChangeSquaresState(1)}
/>

...

意思說我們在父層就先透過 Closure 把 i 的值給傳入了,因此 <Square /> 才能直接接受這個 props,並當成 Even handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Square({ value, handleChangeSquaresState }) {
return (
<button
className="square"
// 這邊 onClick 的值就等於:
// () => handleChangeSquaresState(1)
// () => handleChangeSquaresState(2)
// 以此類推
onClick={handleChangeSquaresState}
>
{value}
</button>
);
}

這就是為什麼就算 <Square /> 不傳任何參數父層也知道要去改陣列的哪一個值。(這個很重要,不懂的話就多想想看)

做到目前這裡,應該會有這樣的結果:

根據 state 顯示 O 或 X

接著要處理 O 跟 X 輪流顯示的問題,所以會用到一開始提的另一個 state,xIsNext

這邊假設由 X 先下棋,所以初始值設為 true

1
const [xIsNext, setXIsNext] = useState(true);

接著調整一下 handleChangeSquaresState,讓他根據目前 xIsNext 來決定資料要長怎樣:

1
2
3
4
5
6
7
8
const handleChangeSquaresState = (i) => {
const newSquares = squares.slice();
// 根據 state 做判斷
newSquares[i] = xIsNext ? "X" : "O";
setSquares(newSquares);
// 最後別忘了把值更新
setXIsNext(!xIsNext);
};

做到這邊就可以正確顯示 O 或 X 了,不過要注意一個問題,就是重複點擊

所以這部分得要多做一層處理:

1
2
3
4
5
6
7
8
const handleChangeSquaresState = (i) => {
// 已經有值的話就不往下執行
if (squares[i]) return;
const newSquares = squares.slice();
newSquares[i] = xIsNext ? "X" : "O";
setSquares(newSquares);
setXIsNext(!xIsNext);
};

這樣就解決剛剛的 bug 了。

顯示目前的 player

接著來加上一段顯示目前 player 的訊息,在 <Board /> 裡面加上這段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
const [xIsNext, setXIsNext] = useState(true);
// 根據 state 顯示不同訊息
const status = `Next player is ${xIsNext ? "X" : "O"}`;

// ...

return (
<div>
// 加上 DOM 元素
<div className="status">{status}</div>
<div className="board-row">
{renderSquare(0)}
{renderSquare(1)}
{renderSquare(2)}
</div>
<div className="board-row">
{renderSquare(3)}
{renderSquare(4)}
{renderSquare(5)}
</div>
<div className="board-row">
{renderSquare(6)}
{renderSquare(7)}
{renderSquare(8)}
</div>
</div>
);
}

做到這邊後會長這樣:

判斷輸贏

接下來是最後一步,判斷誰輸誰贏。這邊要注意一下判斷的時機點該放在哪裡

其實會有兩個地方:

  • 第一個是 state 更新完畢時,畫面要根據 state 顯示贏家
  • 第二個是在改變 state 以前,要先判斷是不是已經有贏家了?

我們先來做第一個部分,首先先處理好判斷輸贏的 function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function calculateWinner(squares) {
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6]
];
for (let i = 0; i < lines.length; i++) {
const [a, b, c] = lines[i];
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
}

接著要在 <Board /> 每一次 re-render 後根據計算結果顯示對應的訊息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
const [xIsNext, setXIsNext] = useState(true);
// 每一次 re-render 就輸贏計算
const winner = calculateWinner(squares);
// status 不再是 const,而是根據判斷結果來決定
let status;
// 有分出勝負
if (winner) {
status = `Winner is ${winner}`;
} else if (!winner && !squares.includes(null)) {
status = `draw`; // 沒有贏家 && 棋盤已經填滿了(都有值)
} else {
status = `Next player is ${xIsNext ? "X" : "O"}`;
}

// ...
}

這邊做完後,會像這樣

確實能判斷輸贏了,不過同時也注意到會有個 bug,就是已經贏了卻還能繼續下棋,所以一開始才特別說其實有兩個時機點。

接著來處理第二個地方,在改變 state 以前的判斷。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
const [xIsNext, setXIsNext] = useState(true);

// 改變 state 以前
const handleChangeSquaresState = (i) => {
// 計算輸贏
const winner = calculateWinner(squares);
// 已經分出勝負 || 已經有值,就不往下執行
if (winner || squares[i]) return;
const newSquares = squares.slice();
newSquares[i] = xIsNext ? "X" : "O";
setSquares(newSquares);
setXIsNext(!xIsNext);
};
}

這樣子就能在正確的時機點停止遊戲了。

做到這邊,這個遊戲就已經完成囉,恭喜恭喜!

最後附上完整的原始碼:Codesandbox

mentor-program-day122 mentor-program-day121
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×