Reactで三目並べの続き
March 26, 2017
React の公式チュートリアルの三目並べには以下のように続きがあります。
Now, you’ve made a tic-tac-toe game that:
- lets you play tic-tac-toe,
- indicates when one player has won the game,
- stores the history of moves during the game,
- allows players to jump back in time to see older versions of the game board.
Nice work! We hope you now feel like you have a decent grasp on how React works. If you have extra time or want to practice your new skills, here are some ideas for improvements you could make, listed in order of increasing difficulty:
- Display the move locations in the format “(1, 3)” instead of “6”.
- Bold the currently-selected item in the move list.
- Rewrite Board to use two loops to make the squares instead of hardcoding them.
- Add a toggle button that lets you sort the moves in either ascending or descending order.
- When someone wins, highlight the three squares that caused the win.
前回は基本の三目並べを作ったので、今回はこの続きをやってみます。
最終的な成果物はこちら ↓
1. 位置の表示
各手番でどこに X または O をおいたのかを履歴に表示します。
Game
コンポーネントのhandleClick()
でクリック後に新しいstate
をセットするとき、そこに位置も計算してセットします(location
の部分)。
import React, { Component } from 'react'; | |
import Board from './Board'; | |
class Game extends Component { | |
constructor() { | |
super(); | |
this.state = { | |
stepNumber: 0, | |
history: [{ | |
squares: Array(9).fill({ value: null, highlighted: false }), | |
location: null | |
}], | |
xIsNext: true, | |
ascending: true | |
}; | |
} | |
handleClick(i) { | |
const history = this.state.history.slice(0, this.state.stepNumber + 1); | |
const current = history[this.state.stepNumber]; | |
const squares = current.squares.slice(); | |
if (calculateWinner(squares) || squares[i].value) { | |
return; | |
} | |
squares[i] = { value: this.state.xIsNext ? 'X' : 'O', highlighted: false }; | |
const winLine = calculateWinner(squares); | |
if (winLine) { | |
for (let j of winLine) { | |
squares[j] = { value: squares[j].value, highlighted: true }; | |
} | |
} | |
const x = (i % 3) + 1; | |
const y = Math.floor(i / 3) + 1; | |
this.setState({ | |
stepNumber: history.length, | |
history: history.concat([{ | |
squares: squares, | |
location: { | |
x: x, | |
y: y | |
} | |
}]), | |
xIsNext: !this.state.xIsNext | |
}); | |
} | |
jumpTo(step) { | |
this.setState({ | |
stepNumber: step, | |
xIsNext: (step % 2) ? false : true | |
}); | |
} | |
toggleOrder() { | |
this.setState({ | |
ascending: !this.state.ascending | |
}); | |
} | |
render() { | |
const history = this.state.history; | |
const current = history[this.state.stepNumber]; | |
const winLine = calculateWinner(current.squares); | |
const descending = !this.state.ascending; | |
let status; | |
if (winLine) { | |
status = 'Winner: ' + current.squares[winLine[0]].value; | |
} else { | |
status = 'Next player: ' + (this.state.xIsNext? 'X' : 'O'); | |
} | |
let moves = history.map((step, move) => { | |
let desc = step.location ? 'Move (' + step.location.x + ', ' + step.location.y + ')' : 'Game start'; | |
if (move === this.state.stepNumber) { | |
desc = <b>{desc}</b>; | |
} | |
return ( | |
<li key={move}> | |
<a href="#" onClick={() => this.jumpTo(move)}>{desc}</a> | |
</li> | |
); | |
}); | |
if (descending) { | |
moves.sort((a, b) => { return b.key - a.key; }); | |
} | |
return ( | |
<div className="game"> | |
<div className="game-board"> | |
<Board | |
squares={current.squares} | |
onClick={(i) => this.handleClick(i)} | |
/> | |
</div> | |
<div className="game-info"> | |
<div>{status}</div> | |
<div><button onClick={() => this.toggleOrder()}>Toggle order</button></div> | |
<ol>{moves}</ol> | |
</div> | |
</div> | |
); | |
} | |
} | |
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].value && squares[a].value === squares[b].value && squares[a].value === squares[c].value) { | |
return [a, b, c]; | |
} | |
} | |
return null; | |
} | |
export default Game; |
あとはレンダリングするときにこれを表示してあげます。
import React, { Component } from 'react'; | |
import Board from './Board'; | |
class Game extends Component { | |
constructor() { | |
super(); | |
this.state = { | |
stepNumber: 0, | |
history: [{ | |
squares: Array(9).fill({ value: null, highlighted: false }), | |
location: null | |
}], | |
xIsNext: true, | |
ascending: true | |
}; | |
} | |
handleClick(i) { | |
const history = this.state.history.slice(0, this.state.stepNumber + 1); | |
const current = history[this.state.stepNumber]; | |
const squares = current.squares.slice(); | |
if (calculateWinner(squares) || squares[i].value) { | |
return; | |
} | |
squares[i] = { value: this.state.xIsNext ? 'X' : 'O', highlighted: false }; | |
const winLine = calculateWinner(squares); | |
if (winLine) { | |
for (let j of winLine) { | |
squares[j] = { value: squares[j].value, highlighted: true }; | |
} | |
} | |
const x = (i % 3) + 1; | |
const y = Math.floor(i / 3) + 1; | |
this.setState({ | |
stepNumber: history.length, | |
history: history.concat([{ | |
squares: squares, | |
location: { | |
x: x, | |
y: y | |
} | |
}]), | |
xIsNext: !this.state.xIsNext | |
}); | |
} | |
jumpTo(step) { | |
this.setState({ | |
stepNumber: step, | |
xIsNext: (step % 2) ? false : true | |
}); | |
} | |
toggleOrder() { | |
this.setState({ | |
ascending: !this.state.ascending | |
}); | |
} | |
render() { | |
const history = this.state.history; | |
const current = history[this.state.stepNumber]; | |
const winLine = calculateWinner(current.squares); | |
const descending = !this.state.ascending; | |
let status; | |
if (winLine) { | |
status = 'Winner: ' + current.squares[winLine[0]].value; | |
} else { | |
status = 'Next player: ' + (this.state.xIsNext? 'X' : 'O'); | |
} | |
let moves = history.map((step, move) => { | |
let desc = step.location ? 'Move (' + step.location.x + ', ' + step.location.y + ')' : 'Game start'; | |
if (move === this.state.stepNumber) { | |
desc = <b>{desc}</b>; | |
} | |
return ( | |
<li key={move}> | |
<a href="#" onClick={() => this.jumpTo(move)}>{desc}</a> | |
</li> | |
); | |
}); | |
if (descending) { | |
moves.sort((a, b) => { return b.key - a.key; }); | |
} | |
return ( | |
<div className="game"> | |
<div className="game-board"> | |
<Board | |
squares={current.squares} | |
onClick={(i) => this.handleClick(i)} | |
/> | |
</div> | |
<div className="game-info"> | |
<div>{status}</div> | |
<div><button onClick={() => this.toggleOrder()}>Toggle order</button></div> | |
<ol>{moves}</ol> | |
</div> | |
</div> | |
); | |
} | |
} | |
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].value && squares[a].value === squares[b].value && squares[a].value === squares[c].value) { | |
return [a, b, c]; | |
} | |
} | |
return null; | |
} | |
export default Game; |
2. 今のステップを強調
render()
の中で、こんな感じ。
import React, { Component } from 'react'; | |
import Board from './Board'; | |
class Game extends Component { | |
constructor() { | |
super(); | |
this.state = { | |
stepNumber: 0, | |
history: [{ | |
squares: Array(9).fill({ value: null, highlighted: false }), | |
location: null | |
}], | |
xIsNext: true, | |
ascending: true | |
}; | |
} | |
handleClick(i) { | |
const history = this.state.history.slice(0, this.state.stepNumber + 1); | |
const current = history[this.state.stepNumber]; | |
const squares = current.squares.slice(); | |
if (calculateWinner(squares) || squares[i].value) { | |
return; | |
} | |
squares[i] = { value: this.state.xIsNext ? 'X' : 'O', highlighted: false }; | |
const winLine = calculateWinner(squares); | |
if (winLine) { | |
for (let j of winLine) { | |
squares[j] = { value: squares[j].value, highlighted: true }; | |
} | |
} | |
const x = (i % 3) + 1; | |
const y = Math.floor(i / 3) + 1; | |
this.setState({ | |
stepNumber: history.length, | |
history: history.concat([{ | |
squares: squares, | |
location: { | |
x: x, | |
y: y | |
} | |
}]), | |
xIsNext: !this.state.xIsNext | |
}); | |
} | |
jumpTo(step) { | |
this.setState({ | |
stepNumber: step, | |
xIsNext: (step % 2) ? false : true | |
}); | |
} | |
toggleOrder() { | |
this.setState({ | |
ascending: !this.state.ascending | |
}); | |
} | |
render() { | |
const history = this.state.history; | |
const current = history[this.state.stepNumber]; | |
const winLine = calculateWinner(current.squares); | |
const descending = !this.state.ascending; | |
let status; | |
if (winLine) { | |
status = 'Winner: ' + current.squares[winLine[0]].value; | |
} else { | |
status = 'Next player: ' + (this.state.xIsNext? 'X' : 'O'); | |
} | |
let moves = history.map((step, move) => { | |
let desc = step.location ? 'Move (' + step.location.x + ', ' + step.location.y + ')' : 'Game start'; | |
if (move === this.state.stepNumber) { | |
desc = <b>{desc}</b>; | |
} | |
return ( | |
<li key={move}> | |
<a href="#" onClick={() => this.jumpTo(move)}>{desc}</a> | |
</li> | |
); | |
}); | |
if (descending) { | |
moves.sort((a, b) => { return b.key - a.key; }); | |
} | |
return ( | |
<div className="game"> | |
<div className="game-board"> | |
<Board | |
squares={current.squares} | |
onClick={(i) => this.handleClick(i)} | |
/> | |
</div> | |
<div className="game-info"> | |
<div>{status}</div> | |
<div><button onClick={() => this.toggleOrder()}>Toggle order</button></div> | |
<ol>{moves}</ol> | |
</div> | |
</div> | |
); | |
} | |
} | |
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].value && squares[a].value === squares[b].value && squares[a].value === squares[c].value) { | |
return [a, b, c]; | |
} | |
} | |
return null; | |
} | |
export default Game; |
3. マスのハードコーディングの修正
Board
コンポーネントでマスをハードコーディングしていたので、それの修正。
renderRow()
を定義して、render()
の中で呼びます。
import React, { Component } from 'react'; | |
import Square from './Square'; | |
class Board extends Component { | |
renderSquare(i) { | |
return <Square key={i} | |
value={this.props.squares[i].value} | |
onClick={() => this.props.onClick(i)} | |
highlighted={this.props.squares[i].highlighted} />; | |
} | |
renderRow(i) { | |
let squares = []; | |
const start = i * 3; | |
for (let j = start; j < start + 3; j++) { | |
squares.push(this.renderSquare(j)); | |
} | |
return ( | |
<div key={i} className="board-row"> | |
{squares} | |
</div> | |
); | |
} | |
render() { | |
let rows = []; | |
for (let i = 0; i < 3; i++) { | |
rows.push(this.renderRow(i)); | |
} | |
return ( | |
<div> | |
{rows} | |
</div> | |
); | |
} | |
} | |
export default Board; |
4. 履歴の表示順序
やることはざっとこんな感じです。
- 履歴の表示順序をトグルするボタンを設置
state
に昇順か降順かの状態を持たせるrender()
でstate
に応じて履歴の表示順序を切り替え
import React, { Component } from 'react'; | |
import Board from './Board'; | |
class Game extends Component { | |
constructor() { | |
super(); | |
this.state = { | |
stepNumber: 0, | |
history: [{ | |
squares: Array(9).fill({ value: null, highlighted: false }), | |
location: null | |
}], | |
xIsNext: true, | |
ascending: true | |
}; | |
} | |
handleClick(i) { | |
const history = this.state.history.slice(0, this.state.stepNumber + 1); | |
const current = history[this.state.stepNumber]; | |
const squares = current.squares.slice(); | |
if (calculateWinner(squares) || squares[i].value) { | |
return; | |
} | |
squares[i] = { value: this.state.xIsNext ? 'X' : 'O', highlighted: false }; | |
const winLine = calculateWinner(squares); | |
if (winLine) { | |
for (let j of winLine) { | |
squares[j] = { value: squares[j].value, highlighted: true }; | |
} | |
} | |
const x = (i % 3) + 1; | |
const y = Math.floor(i / 3) + 1; | |
this.setState({ | |
stepNumber: history.length, | |
history: history.concat([{ | |
squares: squares, | |
location: { | |
x: x, | |
y: y | |
} | |
}]), | |
xIsNext: !this.state.xIsNext | |
}); | |
} | |
jumpTo(step) { | |
this.setState({ | |
stepNumber: step, | |
xIsNext: (step % 2) ? false : true | |
}); | |
} | |
toggleOrder() { | |
this.setState({ | |
ascending: !this.state.ascending | |
}); | |
} | |
render() { | |
const history = this.state.history; | |
const current = history[this.state.stepNumber]; | |
const winLine = calculateWinner(current.squares); | |
const descending = !this.state.ascending; | |
let status; | |
if (winLine) { | |
status = 'Winner: ' + current.squares[winLine[0]].value; | |
} else { | |
status = 'Next player: ' + (this.state.xIsNext? 'X' : 'O'); | |
} | |
let moves = history.map((step, move) => { | |
let desc = step.location ? 'Move (' + step.location.x + ', ' + step.location.y + ')' : 'Game start'; | |
if (move === this.state.stepNumber) { | |
desc = <b>{desc}</b>; | |
} | |
return ( | |
<li key={move}> | |
<a href="#" onClick={() => this.jumpTo(move)}>{desc}</a> | |
</li> | |
); | |
}); | |
if (descending) { | |
moves.sort((a, b) => { return b.key - a.key; }); | |
} | |
return ( | |
<div className="game"> | |
<div className="game-board"> | |
<Board | |
squares={current.squares} | |
onClick={(i) => this.handleClick(i)} | |
/> | |
</div> | |
<div className="game-info"> | |
<div>{status}</div> | |
<div><button onClick={() => this.toggleOrder()}>Toggle order</button></div> | |
<ol>{moves}</ol> | |
</div> | |
</div> | |
); | |
} | |
} | |
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].value && squares[a].value === squares[b].value && squares[a].value === squares[c].value) { | |
return [a, b, c]; | |
} | |
} | |
return null; | |
} | |
export default Game; |
import React, { Component } from 'react'; | |
import Board from './Board'; | |
class Game extends Component { | |
constructor() { | |
super(); | |
this.state = { | |
stepNumber: 0, | |
history: [{ | |
squares: Array(9).fill({ value: null, highlighted: false }), | |
location: null | |
}], | |
xIsNext: true, | |
ascending: true | |
}; | |
} | |
handleClick(i) { | |
const history = this.state.history.slice(0, this.state.stepNumber + 1); | |
const current = history[this.state.stepNumber]; | |
const squares = current.squares.slice(); | |
if (calculateWinner(squares) || squares[i].value) { | |
return; | |
} | |
squares[i] = { value: this.state.xIsNext ? 'X' : 'O', highlighted: false }; | |
const winLine = calculateWinner(squares); | |
if (winLine) { | |
for (let j of winLine) { | |
squares[j] = { value: squares[j].value, highlighted: true }; | |
} | |
} | |
const x = (i % 3) + 1; | |
const y = Math.floor(i / 3) + 1; | |
this.setState({ | |
stepNumber: history.length, | |
history: history.concat([{ | |
squares: squares, | |
location: { | |
x: x, | |
y: y | |
} | |
}]), | |
xIsNext: !this.state.xIsNext | |
}); | |
} | |
jumpTo(step) { | |
this.setState({ | |
stepNumber: step, | |
xIsNext: (step % 2) ? false : true | |
}); | |
} | |
toggleOrder() { | |
this.setState({ | |
ascending: !this.state.ascending | |
}); | |
} | |
render() { | |
const history = this.state.history; | |
const current = history[this.state.stepNumber]; | |
const winLine = calculateWinner(current.squares); | |
const descending = !this.state.ascending; | |
let status; | |
if (winLine) { | |
status = 'Winner: ' + current.squares[winLine[0]].value; | |
} else { | |
status = 'Next player: ' + (this.state.xIsNext? 'X' : 'O'); | |
} | |
let moves = history.map((step, move) => { | |
let desc = step.location ? 'Move (' + step.location.x + ', ' + step.location.y + ')' : 'Game start'; | |
if (move === this.state.stepNumber) { | |
desc = <b>{desc}</b>; | |
} | |
return ( | |
<li key={move}> | |
<a href="#" onClick={() => this.jumpTo(move)}>{desc}</a> | |
</li> | |
); | |
}); | |
if (descending) { | |
moves.sort((a, b) => { return b.key - a.key; }); | |
} | |
return ( | |
<div className="game"> | |
<div className="game-board"> | |
<Board | |
squares={current.squares} | |
onClick={(i) => this.handleClick(i)} | |
/> | |
</div> | |
<div className="game-info"> | |
<div>{status}</div> | |
<div><button onClick={() => this.toggleOrder()}>Toggle order</button></div> | |
<ol>{moves}</ol> | |
</div> | |
</div> | |
); | |
} | |
} | |
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].value && squares[a].value === squares[b].value && squares[a].value === squares[c].value) { | |
return [a, b, c]; | |
} | |
} | |
return null; | |
} | |
export default Game; |
5. 勝利ラインのハイライト
これはちょっと面倒ですが、Square
コンポーネントに
勝利に起因したかマスかどうかを伝える必要があります。
まずSquare
コンポーネントは以下のように、props
によってハイライトするかどうか受けられるようにします。
import React, { Component } from 'react'; | |
export default class Square extends Component { | |
render() { | |
let className = 'square'; | |
if (this.props.highlighted) { | |
className += ' highlighted'; | |
} | |
return ( | |
<button className={className} onClick={() => this.props.onClick()}> | |
{this.props.value} | |
</button> | |
); | |
} | |
} |
それでGame
コンポーネントでは次のように、value
だけでなくハイライトするかどうかを表す値highlighted
も保持するようにします。
import React, { Component } from 'react'; | |
import Board from './Board'; | |
class Game extends Component { | |
constructor() { | |
super(); | |
this.state = { | |
stepNumber: 0, | |
history: [{ | |
squares: Array(9).fill({ value: null, highlighted: false }), | |
location: null | |
}], | |
xIsNext: true, | |
ascending: true | |
}; | |
} | |
handleClick(i) { | |
const history = this.state.history.slice(0, this.state.stepNumber + 1); | |
const current = history[this.state.stepNumber]; | |
const squares = current.squares.slice(); | |
if (calculateWinner(squares) || squares[i].value) { | |
return; | |
} | |
squares[i] = { value: this.state.xIsNext ? 'X' : 'O', highlighted: false }; | |
const winLine = calculateWinner(squares); | |
if (winLine) { | |
for (let j of winLine) { | |
squares[j] = { value: squares[j].value, highlighted: true }; | |
} | |
} | |
const x = (i % 3) + 1; | |
const y = Math.floor(i / 3) + 1; | |
this.setState({ | |
stepNumber: history.length, | |
history: history.concat([{ | |
squares: squares, | |
location: { | |
x: x, | |
y: y | |
} | |
}]), | |
xIsNext: !this.state.xIsNext | |
}); | |
} | |
jumpTo(step) { | |
this.setState({ | |
stepNumber: step, | |
xIsNext: (step % 2) ? false : true | |
}); | |
} | |
toggleOrder() { | |
this.setState({ | |
ascending: !this.state.ascending | |
}); | |
} | |
render() { | |
const history = this.state.history; | |
const current = history[this.state.stepNumber]; | |
const winLine = calculateWinner(current.squares); | |
const descending = !this.state.ascending; | |
let status; | |
if (winLine) { | |
status = 'Winner: ' + current.squares[winLine[0]].value; | |
} else { | |
status = 'Next player: ' + (this.state.xIsNext? 'X' : 'O'); | |
} | |
let moves = history.map((step, move) => { | |
let desc = step.location ? 'Move (' + step.location.x + ', ' + step.location.y + ')' : 'Game start'; | |
if (move === this.state.stepNumber) { | |
desc = <b>{desc}</b>; | |
} | |
return ( | |
<li key={move}> | |
<a href="#" onClick={() => this.jumpTo(move)}>{desc}</a> | |
</li> | |
); | |
}); | |
if (descending) { | |
moves.sort((a, b) => { return b.key - a.key; }); | |
} | |
return ( | |
<div className="game"> | |
<div className="game-board"> | |
<Board | |
squares={current.squares} | |
onClick={(i) => this.handleClick(i)} | |
/> | |
</div> | |
<div className="game-info"> | |
<div>{status}</div> | |
<div><button onClick={() => this.toggleOrder()}>Toggle order</button></div> | |
<ol>{moves}</ol> | |
</div> | |
</div> | |
); | |
} | |
} | |
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].value && squares[a].value === squares[b].value && squares[a].value === squares[c].value) { | |
return [a, b, c]; | |
} | |
} | |
return null; | |
} | |
export default Game; |
あとは勝利判定のときにこの値を変えて、Square
まで渡るようにすれば OK です。
大まかなアイデアはこんな感じで、ソースコードはGistに上げたのでこちらを確認してください。