saitoxu.io

AboutTwitterGitHub

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:

  1. Display the move locations in the format “(1, 3)” instead of “6”.
  2. Bold the currently-selected item in the move list.
  3. Rewrite Board to use two loops to make the squares instead of hardcoding them.
  4. Add a toggle button that lets you sort the moves in either ascending or descending order.
  5. When someone wins, highlight the three squares that caused the win.

前回は基本の三目並べを作ったので、今回はこの続きをやってみます。

最終的な成果物はこちら ↓

react-tic-tac-toe-next

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;
view raw Game.js hosted with ❤ by GitHub

あとはレンダリングするときにこれを表示してあげます。

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;
view raw Game.js hosted with ❤ by GitHub

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;
view raw Game.js hosted with ❤ by GitHub

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;
view raw Board.js hosted with ❤ by GitHub

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;
view raw Game.js hosted with ❤ by GitHub

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;
view raw Game.js hosted with ❤ by GitHub

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>
);
}
}
view raw Square.js hosted with ❤ by GitHub

それで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;
view raw Game.js hosted with ❤ by GitHub

あとは勝利判定のときにこの値を変えて、Squareまで渡るようにすれば OK です。

大まかなアイデアはこんな感じで、ソースコードはGistに上げたのでこちらを確認してください。


© 2021, Yosuke Saito