[前端框架] React - 井字遊戲


本篇根據官方學習指南所建議的 互動小遊戲 進行實作練習。差別於官方以 0 程度進行新手講解,本內容根據已經學習過前面文章初階篇之程度來進行實作解說,因此步驟程度較快。並最後嘗試完成官方建議的進階難度的自我挑戰調整。

遊戲設計需求如下:

  • 讓你玩井字遊戲
  • 顯示哪一個玩家取得勝利
  • 在遊戲進行的同時儲存遊戲歷史
  • 讓玩家回顧遊戲的歷史,並回到棋盤之前的版本

初始視覺版型提供如下(微調過移除不必要的 div):

index.html
<div class="game">
<div class="game-board">
<div class="status">Next player: X</div>
<div class="board-row">
<button class="square"></button>
</div>
<div class="board-row">
<button class="square"></button>
</div>
<div class="board-row">
<button class="square"></button>
</div>
</div>
<div class="game-info">
<div></div>
<ol></ol>
</div>
</div>
index.css
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}

ol, ul {
padding-left: 30px;
}

.board-row:after {
clear: both;
content: "";
display: table;
}

.status {
margin-bottom: 10px;
}

.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}

.square:focus {
outline: none;
}

.kbd-navigation .square:focus {
background: #ddd;
}

#game {
display: flex;
flex-direction: row;
}

.game-info {
margin-left: 20px;
}

初始化與基本畫面

先跟隨官方腳步,將九宮格透過 component 繪製出來。

  1. 首先透過 react 架設所需環境。
    npx create-react-app game
    cd game
    npm start
  2. 清除到乾淨的應用環境,保留 src 底下的 index.css 與 index.js,public 只保留 index.html 並清乾淨。
  3. 將前面素材 index.css 覆蓋至 src/index.css,素材 index.html 覆蓋至 public/index.html。

初始化 index.js

接著試著搬移 index.html 的元素代碼至 index.js 內的 componet 以 render 來完成渲染。

  1. <div class="game">...</div>形成一個組件 Game
  2. <div class="game-board">...</div>形成一個組件 Board
  3. <button class="square"></button>形成一個組件 Square
  4. 記得透過 import 來將 index.css 載入
  5. 如果不想獲得警告,JSC 不喜歡 class 而是用 className 來指定原本 HTML 屬性
    scr/index.js
    import { Component } from 'react';
    import ReactDOM from 'react-dom';
    import './index.css';

    class Square extends Component {
    render() {
    return (<button className="square"></button>);
    }
    }

    class Board extends Component {
    render() {
    return (
    <div className="game-board">
    <div className="status">Next player: X</div>
    <div className="board-row">
    <Square />
    </div>
    <div className="board-row">
    <Square />
    </div>
    <div className="board-row">
    <Square />
    </div>
    </div>
    );
    }
    }

    class Game extends Component {
    render() {
    return (
    <>
    <Board />
    <div className="game-info">
    <div></div>
    <ol></ol>
    </div>
    </>
    );
    }
    }

    ReactDOM.render(
    <Game />,
    document.getElementById('game')
    );

標記 Square 的編號

為了之後計算 Square 的來源為誰,規劃 props.value 由上層 Board 提供編號給下層 Square。

  1. 調整 Board 原本匯入 Square 的方式,改由透過本地函式來觸發,這樣使得 JSX 的畫面比較簡單整潔。
  2. 方便測試,可以對 Square 嘗試渲染出 props.value。
    src/index.js
    //...
    class Square extends Component {
    render() {
    return (<button className="square">{this.props.value}</button>);
    }
    }

    class Board extends Component {
    renderSquare(i) {
    return <Square value={i} />
    }
    render() {
    return (
    <div className="game-board">
    <div className="status">Next player: X</div>
    <div className="board-row">
    {this.renderSquare(0)} {/*等價 <Square value={0}/> 只是畫面比較亂*/}
    {this.renderSquare(1)}
    {this.renderSquare(2)}
    </div>
    <div className="board-row">
    {this.renderSquare(3)}
    {this.renderSquare(4)}
    {this.renderSquare(5)}
    </div>
    <div className="board-row">
    {this.renderSquare(6)}
    {this.renderSquare(7)}
    {this.renderSquare(8)}
    </div>
    </div>
    );
    }
    }
    //...

click 事件下的變化

初始化階段下,接著需要對每個 Square 產生 click 事件替換內容為 X。

  1. 因為是互動,規劃本地 state 初始為空字串,隨被點擊後修改 state 為 X 字串。
  2. 這裡為了簡單,指定一觸發 onClick 時直接執行 this.setState 透過箭頭函式包覆。
scr/index.js
//...
class Square extends Component {
state = {
value: ''
}
render() {
return (
<button className="square"
onClick={
() => this.setState({ value: 'X' })
}
>{this.state.value}</button>
);
}
}
//...

規劃遊戲

目前為止已完成基本需要的畫面,接下來,正式加入一些遊戲機制。

交互放入 O 與 X

為了能計算出各自 Square 的值組合判斷出勝負,我們必須將原本在 Square 內的 state.value 提升到上層 Board,才能統一由上層來管理 9 組 square。

  1. 首先將 square 內的 state 改到上層 Board 去,再透過 props 傳遞 state 與 setState 給下層使用。
  2. Board 的 state 建立陣列長度 9,初始為 null 狀態。隨後如果有被 click 則更改為 X 字串值。
  3. 利用 Board 的 render 部分稍早有提供 i 位置參考值,因此規劃 doClick(i) 發布給 9 個 square 使用。
  4. Square 組件本身捨棄原本的 state.value,改由上層的 prop 過來的 value 使用,而有人對此 Square 進行 click 則是觸發上層的 doClick。
    //...
    class Square extends Component {
    render() {
    return (
    <button className="square"
    onClick={this.props.onClick}
    >{this.props.value}</button>
    );
    }
    }

    class Board extends Component {
    state = {
    squares: Array(9).fill(null)
    }

    doClick = (i) => {
    const squares = this.state.squares.slice(); //copy
    squares[i] = 'X';
    this.setState({ squares: squares });
    }

    renderSquare(i) {
    return <Square
    value={this.state.squares[i]}
    onClick={() => this.doClick(i)}
    />
    }

    render() {
    return (
    <div className="game-board">
    <div className="status">Next player: X</div>
    <div className="board-row">
    {this.renderSquare(0)} {/*等價 <Square value={0}/> 只是畫面比較亂*/}
    {this.renderSquare(1)}
    {this.renderSquare(2)}
    </div>
    <div className="board-row">
    {this.renderSquare(3)}
    {this.renderSquare(4)}
    {this.renderSquare(5)}
    </div>
    <div className="board-row">
    {this.renderSquare(6)}
    {this.renderSquare(7)}
    {this.renderSquare(8)}
    </div>
    </div>
    );
    }
    }
    //...

接著,為了讓每次點擊都是 X 與 O 替換,我們需要在 Board 組件內多一個 state 來做切換。

  1. 追加一個 state 為 xisNext,初始為 true,代表目前將是 X。
  2. 在 doClick 內,對陣列指定處塞入值之前透過三元運算子判斷該塞 O 還是 X。
  3. 對 setState 追加 xisNext 做顛倒。
  4. 順便對標題也活動起來使用三元運算子。
  5. 最後,如果按過的位置就不要讓他觸發 event 之後的動作
    class Board extends Component {
    state = {
    squares: Array(9).fill(null),
    xisNext: true
    }

    doClick = (i) => {
    const squares = this.state.squares.slice(); //copy
    if(squares[i]) return; //如果被點過就不發動後面動作

    // squares[i] = 'X';
    squares[i] = this.state.xisNext ? 'X' : 'O';

    this.setState({
    squares: squares,
    xisNext: !this.state.xisNext
    });
    }

    renderSquare(i) {
    return <Square
    value={this.state.squares[i]}
    onClick={() => this.doClick(i)}
    />
    }

    render() {
    return (
    <div className="game-board">
    <div className="status">Next player: {this.state.xisNext ? 'X' : 'O'}</div>
    <div className="board-row">
    {this.renderSquare(0)} {/*等價 <Square value={0}/> 只是畫面比較亂*/}
    {this.renderSquare(1)}
    {this.renderSquare(2)}
    </div>
    <div className="board-row">
    {this.renderSquare(3)}
    {this.renderSquare(4)}
    {this.renderSquare(5)}
    </div>
    <div className="board-row">
    {this.renderSquare(6)}
    {this.renderSquare(7)}
    {this.renderSquare(8)}
    </div>
    </div>
    );
    }
    }

判斷勝負

判斷勝負的方式,我們需要一個檢查用的函式幫忙確定那些位置下的值當存在且相同時代表已勝利。接著一但獲得勝利嘗試畫面顯示且不再允許繼續 click 動作。

  1. 規劃一函式 checkWin,提供 state.square 資料做批次檢查,一但指定某 3 處位置下的值存在且相同,則回傳此值。
    function checkWin(square) {
    const winLine = [
    [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 < winLine.length; i++) {
    const [a, b, c] = winLine[i];
    if (square[a] && square[a] === square[b] && square[a] === square[c]) return square[a];
    }
    return null;
    }
  2. 在 Board 組件進行 render 當下,先執行檢查 checkWin 回傳,若獲得值則提供勝利的畫面。
  3. 在 Board 組件提供 click 動作時,先執行檢查 checkWin 回傳,若獲得值則不執行後續動作。
    class Board extends Component {
    state = {
    squares: Array(9).fill(null),
    xisNext: true
    }

    doClick = (i) => {
    const squares = this.state.squares.slice(); //copy

    if (checkWin(squares) || squares[i]) return; //如果檢查勝利有結果,或者被點過就不發動後續

    squares[i] = this.state.xisNext ? 'X' : 'O';
    this.setState({
    squares: squares,
    xisNext: !this.state.xisNext
    });
    }

    renderSquare(i) {
    return <Square
    value={this.state.squares[i]}
    onClick={() => this.doClick(i)}
    />
    }

    render() {
    const checkWinner = checkWin(this.state.squares);
    const status = checkWinner ? 'Winner: ' + checkWinner : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

    return (
    <div className="game-board">
    <div className="status">{status}</div>
    <div className="board-row">
    {this.renderSquare(0)} {/*等價 <Square value={0}/> 只是畫面比較亂*/}
    {this.renderSquare(1)}
    {this.renderSquare(2)}
    </div>
    <div className="board-row">
    {this.renderSquare(3)}
    {this.renderSquare(4)}
    {this.renderSquare(5)}
    </div>
    <div className="board-row">
    {this.renderSquare(6)}
    {this.renderSquare(7)}
    {this.renderSquare(8)}
    </div>
    </div>
    );
    }
    }

歷史紀錄清單

最後是要提供一個歷史紀錄的清單選取功能。大致想法為將每次原本在 Board 組件內的 state.squares 陣列變化透過另一個 history 陣列都保留下來,如果要回朔到某狀態的 state.squares 陣列就透過這個 history 某位置內的來覆蓋。

提升 state 至 Game

為了規劃歷史紀錄功能,由於歷史紀錄的 JSX 位置在 Game 組件上,同時 history 規劃在 Game 組件上也能確保下層 Borard 能夠被使用。因此我們必須要將 Board 的 state.squares 提升到 Game 裡去,但 Game 組件我們要創建更龐大的陣列 history。另外 state.xisNext 也要提升上來。

  1. 消除原本 Borad 的 state,改在 Game 組件內提升並以 state.history 陣列型態做初始化。
  2. 接著原本的 Board 的 render 部分消除,提升到 Game 組件的 render 去並調整寫法。原本要檢查的state.squares變成是在state.history[history.length-1](最後一處)上。
  3. 將要顯示的 status 遊戲文字改在 Game 的另半<div className="game-info">呈現,取消並不再透過 Board 做渲染。
  4. 目前 history 最後的 squares 要傳給 Board 來渲染。透過<Board squares={historyLast} />完成。
  5. 而 Board 內的 squares 已經不是 this.state 而是 this.props 了。doClick 也消除用不到(先註解),這裡之後由上層來負責。所以要修改的地方只剩 renderSquare 內。
    class Board extends Component {
    // state = {
    // squares: Array(9).fill(null),
    // xisNext: true
    // }

    // doClick = (i) => {
    // const squares = this.state.squares.slice(); //copy

    // if (checkWin(squares) || squares[i]) return; //如果檢查勝利有結果,或者被點過就不發動後續

    // squares[i] = this.state.xisNext ? 'X' : 'O';
    // this.setState({
    // squares: squares,
    // xisNext: !this.state.xisNext
    // });
    // }

    renderSquare(i) {
    return (<Square
    value={this.props.squares[i]}
    // onClick={() => this.doClick(i)}
    />)
    }

    render() {
    // const checkWinner = checkWin(this.state.squares);
    // const status = checkWinner ? 'Winner: ' + checkWinner : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

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

    class Game extends Component {
    state = {
    history: [{
    squares: Array(9).fill(null)
    }],
    xisNext: true
    }
    render() {
    const history = this.state.history;
    const historyLast = history[this.state.history.length - 1];
    const checkWinner = checkWin(historyLast.squares);

    const status = checkWinner ? 'Winner: ' + checkWinner : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');
    return (
    <>
    <Board squares={historyLast.squares} />
    <div className="game-info">
    <div>{status}</div>
    <ol></ol>
    </div>
    </>
    );
    }
    }

接著原本 Board 內的 doClick 也是提交到 Game 去,由 Game 來提供給 Board ,再由 Board 提供給 Square 組件。

  1. 將原 doClick 提升到 Game 組件內,但注意真正位置在 history 最後處,改寫時是添加到新最後處(因為非同步建議透過 concat 而不是 push)。
  2. 將執行箭頭函式透過 props 傳遞給 Board 組件。該函式動作為提供 i 參數來執行本地 doClick(i)。
  3. 原 Borad 的 this.doClick(i) 變成 this.props.doClick(i)ㄩ
    class Board extends Component {
    // doClick = (i) => {
    // const squares = this.state.squares.slice(); //copy

    // if (checkWin(squares) || squares[i]) return; //如果檢查勝利有結果,或者被點過就不發動後續

    // squares[i] = this.state.xisNext ? 'X' : 'O';
    // this.setState({
    // squares: squares,
    // xisNext: !this.state.xisNext
    // });
    // }
    renderSquare(i) {
    return <Square
    value={this.props.squares[i]}
    onClick={() => this.props.doClick(i)}
    />
    }

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

    class Game extends Component {
    state = {
    history: [{
    squares: Array(9).fill(null)
    }],
    xisNext: true
    }

    doClick = (i) => {
    const history = this.state.history;
    const squares = history[history.length - 1].squares.slice(); //copy

    if (checkWin(squares) || squares[i]) return; //如果檢查勝利有結果,或者被點過就不發動後續

    squares[i] = this.state.xisNext ? 'X' : 'O';
    this.setState({
    history: history.concat([{ squares: squares }]), //陣列合併
    xisNext: !this.state.xisNext
    });
    }

    render() {
    const history = this.state.history;
    const historyLast = history[this.state.history.length - 1];
    const checkWinner = checkWin(historyLast.squares);
    const status = checkWinner ? 'Winner: ' + checkWinner : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

    return (
    <>
    <Board
    squares={historyLast.squares}
    doClick={(i) => this.doClick(i)}
    />
    <div className="game-info">
    <div>{status}</div>
    <ol></ol>
    </div>
    </>
    );
    }
    }

展示歷史畫面

接著需要將步驟紀錄顯示在畫面上,此位置在 Game 組件內處理。

  1. 產生 li 元素標籤陣列 listMoves,透過對 history.map() 來批次產生多組 li。利用 index 值來產生步驟 0~N ,唯獨步驟 0(初始資料下的 squares) 的文字是 GameStart。
  2. 因為 listMoves 標籤陣列提供給 JSX 需要綁一個 key 值,不建議使用數字或 index 來指定,改用第三方npm install --save nanoid來協助隨機碼,key 的位置落在 li 上面。
  3. 最後將這個 li 群組輸出在畫面上。
  4. 先偷綁一個點擊事件,落某 button 被點時觸發要回到第幾筆 squares 狀況。
class Game extends Component {
state = {
history: [{
squares: Array(9).fill(null)
}],
xisNext: true
}

doClick = (i) => {
const history = this.state.history;
const squares = history[history.length - 1].squares.slice(); //copy

if (checkWin(squares) || squares[i]) return; //如果檢查勝利有結果,或者被點過就不發動後續

squares[i] = this.state.xisNext ? 'X' : 'O';
this.setState({
history: history.concat([{ squares: squares }]), //陣列合併
xisNext: !this.state.xisNext
});
}

doJump = (e) => {
console.log(e);
}

render() {
const history = this.state.history;
const historyLast = history[this.state.history.length - 1];
const checkWinner = checkWin(historyLast.squares);
const status = checkWinner ? 'Winner: ' + checkWinner : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

const listMoves = history.map((v, i) => {
const desc = i ? 'Go to move #' + i : 'Go to game start';

return (
<li key={nanoid(4)}>
<button onClick={() => { this.doJump(i) }}>{desc}</button>
</li>
);
});

return (
<>
<Board
squares={historyLast.squares}
doClick={(i) => this.doClick(i)}
/>
<div className="game-info">
<div>{status}</div>
<ol>{listMoves}</ol>
</div>
</>
);
}
}

動作反應

我們需要一個負責記錄目前是畫面為上步驟幾的 state.stepNum,這個值能控制 history 內的第幾格是我們的效果畫面。

  1. 在 Game 組件內初始 state.stepNum 為 0,一開始是步驟 0。

  2. 當 doJump 被觸發時,根據參數為步驟幾來修正 state.stepNum,也就是想要將畫面呈現在步驟幾的紀錄上。

  3. 同上,同時還要修正由於 state.stepNum 而當時的 xisNext 為何,已知步驟 0 下該值為 true,步驟 1 為 false。

  4. Game 每次渲染時,根據 state.stepNum 多少來決定 historyLast 的畫面資料。才能影響其他組件的渲染動作。原本是抓最後一筆做渲染,現在是根據 stepNum 為多少來決定哪筆渲染。

  5. 其次,每次遊戲方塊被點擊時,stepNum 值會遞增,修正 stepNum 值可根據目前 history 長度來獲得。

    class Game extends Component {
    state = {
    history: [{
    squares: Array(9).fill(null)
    }],
    xisNext: true,
    stepNum: 0
    }

    doClick = (i) => {
    const history = this.state.history;
    const squares = history[history.length - 1].squares.slice(); //copy

    if (checkWin(squares) || squares[i]) return; //如果檢查勝利有結果,或者被點過就不發動後續

    squares[i] = this.state.xisNext ? 'X' : 'O';
    this.setState({
    history: history.concat([{ squares: squares }]), //陣列合併
    xisNext: !this.state.xisNext,
    stepNum: history.length
    });
    }

    doJump = (e) => {
    // console.log(e);
    this.setState({
    xisNext: e % 2 === 0,
    stepNum: e
    });

    }

    render() {
    const history = this.state.history;
    // const historyLast = history[this.state.history.length - 1];
    const historyLast = history[this.state.stepNum];
    const checkWinner = checkWin(historyLast.squares);
    const status = checkWinner ? 'Winner: ' + checkWinner : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

    const listMoves = history.map((v, i) => {
    const desc = i ? 'Go to move #' + i : 'Go to game start';

    return (
    <li key={nanoid(4)}>
    <button onClick={() => { this.doJump(i) }}>{desc}</button>
    </li>
    );
    });

    return (
    <>
    <Board
    squares={historyLast.squares}
    doClick={(i) => this.doClick(i)}
    />
    <div className="game-info">
    <div>{status}</div>
    <ol>{listMoves}</ol>
    </div>
    </>
    );
    }
    }
  6. 最後要考量的 BUG 問題。當成功回朔到某舊步驟下時,當下觸發方塊點擊時。原本的 history 歷史紀錄就要捨棄原本後步驟的值。使得該歷史陣列修正到只記錄到目前步驟下的歷史陣列。透過 slice 複製出一個從 0 到 stepNum+1 的陣列範圍作為每次點擊方塊的 history 紀錄。

    class Game extends Component {
    state = {
    history: [{
    squares: Array(9).fill(null)
    }],
    xisNext: true,
    stepNum: 0
    }

    doClick = (i) => {
    // const history = this.state.history;
    const history = this.state.history.slice(0, this.state.stepNum + 1);
    const squares = history[history.length - 1].squares.slice(); //copy

    if (checkWin(squares) || squares[i]) return; //如果檢查勝利有結果,或者被點過就不發動後續

    squares[i] = this.state.xisNext ? 'X' : 'O';
    this.setState({
    history: history.concat([{ squares: squares }]), //陣列合併
    xisNext: !this.state.xisNext,
    stepNum: history.length
    });
    }

    doJump = (e) => {
    this.setState({
    xisNext: e % 2 === 0,
    stepNum: e
    });
    }

    render() {
    const history = this.state.history;
    const historyLast = history[this.state.stepNum];
    const checkWinner = checkWin(historyLast.squares);
    const status = checkWinner ? 'Winner: ' + checkWinner : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

    const listMoves = history.map((v, i) => {
    const desc = i ? 'Go to move #' + i : 'Go to game start';

    return (
    <li key={nanoid(4)}>
    <button onClick={() => { this.doJump(i) }}>{desc}</button>
    </li>
    );
    });

    return (
    <>
    <Board
    squares={historyLast.squares}
    doClick={(i) => this.doClick(i)}
    />
    <div className="game-info">
    <div>{status}</div>
    <ol>{listMoves}</ol>
    </div>
    </>
    );
    }
    }

Full Code

完整程式碼查看

進階挑戰

接續前面程式代碼內容,根據官方建議的練習你的 React 新技巧,下面是要求可以改進圈圈叉叉小遊戲的想法,依照程度逐漸增加困難度:

  • 在歷史動作列表中,用(欄,列)的格式來顯示每個動作的位置。
  • 在動作列表中,將目前被選取的項目加粗。
  • 改寫 Board,使用兩個 loop 建立方格而不是寫死它。
  • 加上一個切換按鈕讓你可以根據每個動作由小到大、由大到小來排序。
  • 當勝負揭曉時,把連成一條線的那三個方格凸顯出來。
  • 當沒有勝負時,顯示遊戲結果為平手。

追加位置標記

由於 li 印出時會從 state.history 來撈出所有當時的 squares(OX 陣列),因此還欠缺一個當經過方塊點擊時,需要多記住目前的位置值 i。之後隨 li 渲染時將值轉為座標。

  1. 修改 state.history 初始狀態,多規劃 atSpace 為 null。
  2. 在點擊方塊的 setState 部分進行修改 history 時記住目前的點擊之 i 值到 atSpace 去。
  3. 對 li 渲染時,從 history.map 抽取每段內容,可以從 v 獲得 squares 與 atSpace。而 atScpace 就是位置代號,需透過除法整數與餘數,其換 09 為 (1,1)(3,3)。
    class Game extends Component {
    state = {
    history: [{
    squares: Array(9).fill(null),
    atSpace: null //紀錄當下點擊來源
    }],
    xisNext: true,
    stepNum: 0
    }

    doClick = (i) => {
    const history = this.state.history.slice(0, this.state.stepNum + 1);
    const squares = history[history.length - 1].squares.slice(); //copy

    if (checkWin(squares) || squares[i]) return; //如果檢查勝利有結果,或者被點過就不發動後續

    squares[i] = this.state.xisNext ? 'X' : 'O';
    this.setState({
    history: history.concat([{
    squares: squares,
    atSpace: i //這個點擊來自哪個位置 0~8
    }]),
    xisNext: !this.state.xisNext,
    stepNum: history.length
    });
    }

    doJump = (e) => {
    this.setState({
    xisNext: e % 2 === 0,
    stepNum: e
    });
    }

    render() {
    const history = this.state.history;
    const historyLast = history[this.state.stepNum];
    const checkWinner = checkWin(historyLast.squares);
    const status = checkWinner ? 'Winner: ' + checkWinner : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

    const listMoves = history.map((v, i) => {
    const desc = i ? 'Go to move #' + i : 'Go to game start';
    const space = '(' + (Math.floor(v.atSpace / 3) + 1) + ',' + (v.atSpace % 3 + 1) + ')'; //將位置轉為座標號碼
    return (
    <li key={nanoid(4)}>
    <button onClick={() => { this.doJump(i) }}>{desc + space}</button>
    </li>
    );
    });

    return (
    <>
    <Board
    squares={historyLast.squares}
    doClick={(i) => this.doClick(i)}
    />
    <div className="game-info">
    <div>{status}</div>
    <ol>{listMoves}</ol>
    </div>
    </>
    );
    }
    }

當前步驟標記

還記得 state.stepNum 是拿來目前是步驟幾,history.map 產生出來 index 可代表所有步驟。這兩個值相同就代表目前 map 回圈下的 button 就是目前步驟表示。

  1. 規劃 state.stepNum 與 map 當下的 i 同值時,對 button 做 style 樣是加粗加紅色。
  2. 注意 style 的內容要吃 object,即使空的也要給予{}空物件。
    //in Game Component
    render() {
    const history = this.state.history;
    const historyLast = history[this.state.stepNum];
    const checkWinner = checkWin(historyLast.squares);
    const status = checkWinner ? 'Winner: ' + checkWinner : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

    const listMoves = history.map((v, i) => {
    const desc = i ? 'Go to move #' + i : 'Go to game start';
    const space = '(' + (Math.floor(v.atSpace / 3) + 1) + ',' + (v.atSpace % 3 + 1) + ')';
    return (
    <li key={nanoid(4)}>
    <button
    onClick={() => { this.doJump(i) }}
    style={this.state.stepNum === i ? { //歷史步驟與輸出清單順序是否相同
    fontWeight: 'bold',
    color: 'red'
    } : {}}>{desc + space}</button>
    </li>
    );
    });

    return (
    <>
    <Board
    squares={historyLast.squares}
    doClick={(i) => this.doClick(i)}
    />
    <div className="game-info">
    <div>{status}</div>
    <ol>{listMoves}</ol>
    </div>
    </>
    );
    }

彈性列數

使用雙迴圈規劃 Board 的方塊。

  1. 利用 for 迴圈規劃標籤陣列,而 key 的指定在於 div.board-row 本身外,內部的 Square 也要提供否則會報警告。
  2. 注意原本的 i 值 0~8 要利用雙迴圈反推回來。
    class Board extends Component {
    renderSquare(i) {
    return <Square
    value={this.props.squares[i]}
    onClick={() => this.props.doClick(i)}
    key={nanoid(4)}
    />
    }

    render() {
    const xN = 3;
    let boardList = [];
    for (let i = 0; i < xN; i++) {
    let tempAry = [];
    for (let j = 0; j < xN; j++) tempAry.push(this.renderSquare(i * xN + j));
    boardList.push(
    <div className="board-row" key={nanoid(4)}>{tempAry}</div>
    );
    }
    return (
    <div className="game-board">{boardList}</div>
    );
    }
    }

切換排列順序

多增加一個按鈕,將輸出的 li 標籤陣列判斷是否反轉渲染。

  1. 規劃一個獨立的 Component 命名為 SortBtn,屆時會提供 Game 的 state.sortASC 與 onClick 事件。使得畫面能切換 button 的文字與動作響應。
    class SortBtn extends Component {
    render() {
    return (
    <button onClick={() => this.props.doClick()}>{this.props.sortASC ? 'ASC' : 'DESC'}</button>
    );
    }
    }
  2. 規劃 Game 組件多一個 state.sortASC 為 true,代表初始正序。以及一個 doSort 事件,每當被點擊時修改此 state 值反轉布林。
  3. render 部分規劃 SortBtn 的位置,並將 state 與 event 事件傳遞該下層去。
  4. 匯出 listMoves 清單時,透過三元運算子根據 state.sortASC 判斷是否需要將標籤陣列反轉。
    class Game extends Component {
    state = {
    history: [{
    squares: Array(9).fill(null),
    atSpace: null //紀錄當下點擊來源
    }],
    xisNext: true,
    stepNum: 0,
    sortASC: true
    }

    doClick = (i) => {
    const history = this.state.history.slice(0, this.state.stepNum + 1);
    const squares = history[history.length - 1].squares.slice(); //copy

    if (checkWin(squares) || squares[i]) return; //如果檢查勝利有結果,或者被點過就不發動後續

    squares[i] = this.state.xisNext ? 'X' : 'O';
    this.setState({
    history: history.concat([{
    squares: squares,
    atSpace: i //這個點擊來自哪個位置 0~8
    }]),
    xisNext: !this.state.xisNext,
    stepNum: history.length
    });
    }

    doJump = (e) => {
    this.setState({
    xisNext: e % 2 === 0,
    stepNum: e
    });
    }

    doSort = () => {
    this.setState({
    sortASC: !this.state.sortASC
    });
    }

    render() {
    const history = this.state.history;
    const historyLast = history[this.state.stepNum];
    const checkWinner = checkWin(historyLast.squares);
    const status = checkWinner ? 'Winner: ' + checkWinner : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

    const listMoves = history.map((v, i) => {
    const desc = i ? 'Go to move #' + i : 'Go to game start';
    const space = '(' + (Math.floor(v.atSpace / 3) + 1) + ',' + (v.atSpace % 3 + 1) + ')'; //將位置轉為座標號碼
    return (
    <li key={nanoid(4)}>
    <button
    onClick={() => { this.doJump(i) }}
    style={this.state.stepNum === i ? {
    fontWeight: 'bold',
    color: 'red'
    } : {}}>{desc + space}</button>
    </li>
    );
    });

    return (
    <>
    <Board
    squares={historyLast.squares}
    doClick={(i) => this.doClick(i)}
    />
    <div className="game-info">
    <div>{status}</div>
    <SortBtn
    sortASC={this.state.sortASC}
    doClick={() => this.doSort()}
    />
    <ol>{this.state.sortASC ? listMoves : listMoves.reverse()}</ol>
    </div>
    </>
    );
    }
    }

勝利標記

當獲得勝利時,需要將勝利的位置特別標示。之前已設計一個 checkWin() 能幫助我們判斷目前棋盤上的組合是否符合勝利,若成立會回傳勝利方的符號。從這裡下手。

  1. 將原本只回傳勝利方符號,改成回傳的是一個陣列且包含勝利方符號與三處位置值。
    function checkWin(square) {
    const winLine = [
    [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 < winLine.length; i++) {
    const [a, b, c] = winLine[i];
    // if (square[a] && square[a] === square[b] && square[a] === square[c]) return square[a];
    if (square[a] && square[a] === square[b] && square[a] === square[c]) return [square[a], a, b, c];
    }
    return null;
    }
  2. 由於異動了此結構,Game 組件有利用回傳值所以要調整一下。至於將回傳值當作布林值的部分不受影響:
    class Game extends Component {
    //...

    render() {
    const history = this.state.history;
    const historyLast = history[this.state.stepNum];
    const checkWinner = checkWin(historyLast.squares);
    const status = checkWinner ? 'Winner: ' + checkWinner[0] : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

    //...
    }
    }
  3. 接著持有 9 個位置 i 值的為 Board 組件,因此在這裡判斷是否要標記並傳遞給下層 Square 組件。由於 checkWin() 宣告處為全域所以可直接使用。
  4. 判斷方式為若 checkWin() 回傳有東西且目前的 i 值存在於陣列內,代表該方塊是需要加標記顯示的。
    class Board extends Component {
    renderSquare(i) {
    const checkWinner = checkWin(this.props.squares);
    return <Square
    value={this.props.squares[i]}
    onClick={() => this.props.doClick(i)}
    key={nanoid(4)}
    winRed={checkWinner && checkWinner.includes(i) ? { color: 'red' } : {}}
    />
    }

    render() {
    const xN = 3;
    let boardList = [];
    for (let i = 0; i < xN; i++) {
    let tempAry = [];
    for (let j = 0; j < xN; j++) tempAry.push(this.renderSquare(i * xN + j));
    boardList.push(
    <div className="board-row" key={nanoid(4)}>{tempAry}</div>
    );
    }
    return (
    <div className="game-board">{boardList}</div>
    );
    }
    }

平局顯示

在 Game 組件內,status 變數是作為提示文字。我們需要在這裡多做一個機制前置判斷。如果 squrares 內已經不存在 null 時(皆存在符號)且無勝利,那就將訊息改為 Draw。

class Game extends Component {
//...

render() {
const history = this.state.history;
const historyLast = history[this.state.stepNum];
const checkWinner = checkWin(historyLast.squares);

let status;
if (!historyLast.squares.includes(null) && !checkWinner) status = 'Draw';
else status = checkWinner ? 'Winner: ' + checkWinner[0] : 'Next player: ' + (this.state.xisNext ? 'X' : 'O');

//...
}
}

Full Code

完整程式碼查看

參考文件