React + Redux でライフゲームを作る
React + Redux でライフゲームを作ってみます。
Reduxでセルの状態を管理し、その状態をReactで描画するというイメージです。Canvasなどを使わずにDOMのスタイルを状態に応じて動的に切り替えて描画するようにしてみます。
ライフゲームというのは、生命の誕生、進化、淘汰などのプロセスを簡易的なモデルで再現したシミュレーションゲームです。詳しくは、Wikipediaを参照してください。
単純なルールで複雑な様相が見れて面白いものです。いわゆるセル・オートマトンと言うやつです。ルールも実装も簡単なのでサンプルの実装にはもってこいと思い選びました。
環境構築から順にまとめます。
環境構築
create-react-app
Reactの開発環境は create-react-app
で作成しますので、まずはこれをインストールします。
$ npm install -g create-react-app
実行して React の開発環境とプロジェクトのディレクトリ構成をばばっと整えます。
$ create-react-app react-redux-lifegame
コマンドを実行するとプロジェクトフォルダが作成され、その下に次のようなディレクトリ構成出来上がります。以下、実装するファイルはすべて src ディレクトリのファイルです。
react-redux-lifegame
├─── node_modules/
├─── public/
├─── package.json
├─── README.md
└─┬─ src/
├─── App.js
├─── index.js
Redux のインストール
次に Redux に必要なツールをインストールします。まずは作成されたプロジェクトディレクトリに移動します。
$ cd react-redux-lifegame
$ npm install --save redux react-redux redux-logger
完成品のイメージ
こんな感じの画面構成で以下の機能を実装します。
- ボタンでスタート、ストップが可能
- 手動(nextボタン)で1世代ずつの遷移が可能
- セルをクリックするとセルの状態を反転
storeの作成
Redux の store は、アプリケーションの状態を管理するオブジェクトです。まずはこれを作成するための処理を作っていきます。
createStore.js
というファイルを作って次のように実装します。
createStore.js
import { createStore as reduxCreateStore, applyMiddleware, combineReducers } from 'redux';
import logger from 'redux-logger';
export default function createStore() {
const store = reduxCreateStore(
combineReducers({
lifegame: (state = {}) => state,
}),
applyMiddleware(
logger,
)
);
return store;
}
全体の処理としては、Redux の createStore関数で作成された store を返す関数を作って export しています。ただし、Redux内にある同名の createStore という関数を内部で使用するので、as を使って “reduxCreateStore” という名前で import しています。
今回は1つの reducer しか使用しない予定ですが、combineReducersで複数に対応できるようにしています。今は何もしないreducerを仮に用意しているだけです。
開発時のReduxの確認を効率よくするため、redux-logger のミドルウェアを適応しています。
React に Redux を組み込む
ReduxをReactに組み込む処理を実装します。index.js
が React のエントリポイント? のファイルです。ここに先ほど実装した storeStore で作成した store を React に組み込みます。
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
import { Provider } from 'react-redux';
import createStore from './createStore';
const store = createStore();
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
registerServiceWorker();
先ほど実装したcreateStore関数で store を作成し、それをProviderタグを使ってルートコンポーネントに当たるAppタグをラップします。これでApp以下のコンポーネントで、storeを扱えるようになります。
コンポーネントを作る
ライフゲームを構成するコンポーネントを作っていきます。面倒なので単一のコンポーネントでライフゲームの盤面、ボタン、スコアを定義します。
src を作成し、componentsディレクトリを作成します。ここにコンポーネントを作成していきます。ということで、Lifegame.jsファイルを作成します。
components/Lifegame.js
import React from 'react';
export default class Lifegame extends React.Component {
render() {
return (
<div>
<h1>ライフゲーム</h1>
<button>START</button>
<button>STOP</button>
<button>NEXT</button>
<button>RESET</button>
<br />
<div style={styles.board}>
{this.renderCells(
[
[true, false, false],
[false, true, false],
[false, false, true],
]
)}
</div>
<div>
<p>世代数: xx</p>
</div>
</div>
)
}
renderCells(cells) {
const result = [];
let index = 0;
for (let y = 0; y < cells.length; y++) {
const row = cells[y];
for (let x = 0; x < row.length; x++) {
// 仮のセルを出力する
const value = row[x];
result.push(
<div key={index++}
style={value ? styles.alive : styles.dead}
/>
);
}
}
return result;
}
}
const styles = {
board: {
display: 'flex',
flexWrap: 'wrap',
margin: '1rem auto',
width: 8*3
},
alive: {
width: 8,
height: 8,
backgroundColor: '#0CF925',
},
dead: {
width: 8,
height: 8,
backgroundColor: '#000',
},
}
ひとまずライフゲームっぽい画面を作成するためのコンポーネントを作成しています。もちろんライフゲームでは動的に描画すべきセルの状態が移り変わりますが、ここでは固定の画面です。
ライフゲームのセルを描画する部分については renderCells
関数に2次元配列を渡して、その状態を描画するようにしています。渡された配列をループし、セルの状態に応じてスタイルを動的に切り替えています。ループで描画するdivタグ(セル)にはkey属性を振るのを忘れずに行います。
ということで、できたコンポーネントを表示してみます。ルートコンポーネントにこれを追加します。
App.js
import React, { Component } from 'react';
import './App.css';
import Lifegame from './components/Lifegame';
class App extends Component {
render() {
return (
<div className="App">
<Lifegame />
</div>
);
}
}
export default App;
ここで画面を表示すると次のようになっているはずです。3*3の配列を引数で渡しましたが、ちゃんとその状態の通りにセルが描画されているのが確認できます。
ちなみに生きたセルが黄緑色で、死んだセルが黒色になっています。
まだなにも動きませんし、描画内容も固定値ですが、それっぽく表示されました。
Action を定義する
- ライフゲーム全体を初期化する
- 次世代に進める
- 指定したサルの状態を設定する
ひとまず上記3つのアクションを定義します。
actionsディレクトリを作成し、Lifegame.jsファイルを作成します。
actions/Lifegame.js
export const initialize = (size) => {
return {
type: 'LIFEGAME_INIT',
payload: {
size: size,
}
}
}
export const next = () => {
return {
type: 'LIFEGAME_NEXT',
}
}
export const setStatus = (x, y, status) => {
return {
type: 'LIFEGAME_SET_STATUS',
payload: {
x: x,
y: y,
status: status,
}
}
}
LIFEGAME_INIT
のアクションは指定したサイズでライフゲームのセルを初期化します。世代も0にします。size
が 10 を指定されると、10*10 のセルをすべて死んだ状態で初期化します。
LIFEGAME_NEXT
は、現在のセルの状態から次世代のセルの状態へ更新するアクションです。
LIFEGAME_SET_STATU
は、セルの位置とその状態を指定することで、そのセルの状態を更新するためのアクションです。
Reducer を実装する
reducer はアクションごとの、ディスパッチ処理を行います。
reducers ディレクトリを作成し、Lifegame.jsファイルを作成します。
reducers/Lifegame.js
const initialState = {
age: 1,
cells: [[]],
}
export default (state=initialState , action) => {
const newState = JSON.parse(JSON.stringify(state));
switch (action.type) {
case 'LIFEGAME_INIT':
// セルをすべてfalseで初期化
const size = action.payload.size;
newState.cells = getInitialCells(size);
newState.age = 1;
return newState;
case 'LIFEGAME_NEXT':
// セルの状態を次世代に進める
newState.cells = getNextCells(newState.cells);
newState.age++;
return newState;
case 'LIFEGAME_SET_STATUS':
// 指定されたセルの状態を設定する
newState.cells[action.payload.y][action.payload.x] = action.payload.status;
return newState;
default:
return state;
}
}
// すべてfalseの2次元配列で初期化したセルを取得
const getInitialCells = (size) => {
const cells = [];
for (let y = 0; y < size; y++) {
const row = [];
for (let x = 0; x < size; x++) {
row.push(false);
}
cells.push(row);
}
return cells;
}
// 次世代のセルの状態を取得する
// ルールの参考: https://ja.wikipedia.org/wiki/%E3%83%A9%E3%82%A4%E3%83%95%E3%82%B2%E3%83%BC%E3%83%A0#%E3%83%A9%E3%82%A4%E3%83%95%E3%82%B2%E3%83%BC%E3%83%A0%E3%81%AE%E3%83%AB%E3%83%BC%E3%83%AB
const getNextCells = (cells) => {
const nextCells = JSON.parse(JSON.stringify(cells));
for (let y = 1; y < cells.length - 1; y++) {
for (let x = 1; x < cells[y].length - 1; x++) {
// 周囲8マスの生きているセルのカウント
const count = cells[y-1][x-1] + cells[y-1][x ] + cells[y-1][x+1] +
cells[y ][x-1] + cells[y ][x+1] +
cells[y+1][x-1] + cells[y+1][x ] + cells[y+1][x+1];
// ルールに従って次世代の状態を判定する
if (cells[y][x] == false) {
if (count == 3) {
// 誕生 - 死んでいるセルに隣接する生きたセルがちょうど3つあれば、次の世代が誕生する。
nextCells[y][x] = true;
}
} else {
if (count <= 1) {
// 過疎 - 生きているセルに隣接する生きたセルが1つ以下ならば、過疎により死滅する。
nextCells[y][x] = false;
} else if (count >= 4) {
// 過密 - 生きているセルに隣接する生きたセルが4つ以上ならば、過密により死滅する。
nextCells[y][x] = false;
} else {
// 生存 - 生きているセルに隣接する生きたセルが2つか3つならば、次の世代でも生存する。
}
}
}
}
return nextCells;
}
盤面の初期化、指定したセルの状態設定は処理の実装は単純なので説明は割愛します。
注意点として state を更新する際には、現在の状態に対して値を上書きしてはいけません。オブジェクトをそのまま使うのではなく、ディープコピーを行って空上書きするようにします。
stateのディープコピー
Object.assign
関数を使えば、オブジェクトのディープコピーを作成できるのですが、配列については1次元配列についてしかディープコピーにならないようです。
今回はセルの情報を2次元配列で保持しているので、これをプロパティとしてもつオブジェクトをコピーするときに、1度JSON文字列へのシリアライズを経由して、JSONオブジェクトにパースするという手順を踏んでいます。
無理やりですが、これでうまく行っているので良しとします。
ライフゲームのルール
今更ですがライフゲームのルールですが、Wkipediaにあるルールを参考にしています。基本的には現在のセルの状態とそのセルの周囲のセルの状態から次世代の状態が決まります。
getNextCells
関数でルールに従ってセルの2次元配列を作成しています。
また、処理を単純にするため一番外側のセルについては番兵として扱い、常にfalseとしています。こうすることで、配列の領域外参照を無視して処理を書くことができます。
Container を定義する
containersディレクトリを作成し、Lifegame.jsファイルを作成します。コンテナはコンポーネントとReduxを組み込みます。
containers/Lifegame.js
import { connect } from 'react-redux';
import * as actions from '../actions/Lifegame';
import Lifegame from '../components/Lifegame';
const mapStateToProps = state => {
return {
count: state.lifegame,
}
}
const mapDispatchToProps = dispatch => {
return {
initialize: (size) => dispatch(actions.initialize(size)),
next: () => dispatch(actions.next()),
setStatus: (x, y, status) => dispatch(actions.setStatus(x, y, status)),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(Lifegame);
コンポーネントの props
に state と ディスパッチを実行する関数を connect
しています。こうすることでコンポーネントが Redux
のことを意識せず、渡された関数やオブジェクトだけを見ていればよいということになります。
Reducerを使えるようにする
Reducer を createStore で組み込みます。combineReducers
に仮に作成していた関数を作成したものに置き換えます。
createStore.js
import { createStore as reduxCreateStore, applyMiddleware, combineReducers } from 'redux';
import logger from 'redux-logger';
import lifegameReducer from './reducers/Lifegame';
export default function createStore() {
const store = reduxCreateStore(
combineReducers({
lifegame: lifegameReducer,
}),
applyMiddleware(
logger,
)
);
return store;
}
コンポーネントで処理を呼び出す
ここまででライフゲームの基礎となる機能ができているので、コンポーネントに処理を追加して動かしてみます。
App.js でコンポーネントの Lifegame
を呼び出している個所を、コンテナから呼び出して出力するようにします。
App.js
// import Lifegame from './components/Lifegame';
import Lifegame from './containers/Lifegame';
コンテナから import するように変更するだけです。こうしても同じように画面にコンポーネントで作成した画面が表示されるはずです。
ではコンテナでマッピングした関数を各ボタンのクリック時の処理として追加し、セルの状態を画面に表示できるようにします。また、コンストラクタ内で30*30のサイズで初期化するようにします。
components/Lifegame.js
import React from 'react';
export default class Lifegame extends React.Component {
constructor(props) {
super(props);
props.initialize(30+2);
}
render() {
return (
<div>
<h1>ライフゲーム</h1>
<button onClick={() => this.start()}>START</button>
<button onClick={() => this.stop()}>STOP</button>
<button onClick={() => this.props.next()}>NEXT</button>
<button onClick={() => this.props.initialize(30+2)}>RESET</button>
<br />
<div style={styles.board}>
{this.renderCells(this.props.cells)}
</div>
<div>
<p>世代数: {this.props.age}</p>
</div>
</div>
)
}
renderCells(cells) {
const result = [];
let index = 0;
// 一番外側のセルは番兵(常にfalse)のため描画しない
for (let y = 1; y < cells.length-1; y++) {
const row = cells[y];
for (let x = 1; x < row.length-1; x++) {
const value = row[x];
result.push(
<div key={index++}
style={value ? styles.alive : styles.dead}
onClick={() => this.props.setStatus(x, y, !this.props.cells[y][x])}
/>
);
}
}
return result;
}
state = {
timerId: null
}
start() {
const timerId = setInterval(this.props.next, 500);
this.setState({ timerId: timerId });
}
stop() {
if (this.state.timerId) {
clearInterval(this.state.timerId);
this.setState({ timerId: null });
}
}
}
const styles = {
board: {
display: 'flex',
flexWrap: 'wrap',
margin: '1rem auto',
width: 8*30
},
alive: {
width: 8,
height: 8,
backgroundColor: '#0CF925',
},
dead: {
width: 8,
height: 8,
backgroundColor: '#000',
},
}
コンポーネントでコンテナからマッピングされた関数を呼び出すようにしています。
コンストラクタ内で盤面のサイズを指定して初期化しています。今回は30*30のサイズで初期化したいのですが、番兵分2つ大きくサイズを指定しています。
スタイルもサイズに合わせて幅を変更しています。
ローカルStateを使う
start ボタンによる繰り返しの処理に setInterval
関数を使っています。stop ボタン押下時にはこの繰り返し処理を停止するために、setInterval 関数によって発行されたIDを引数に指定して clearInterval
関数を実行します。
IDはローカルstateに保持するようにしています。別に Redux による管理をしてもよいのですが、UIに関する状態なのでコンポーネントで保持してもいいと思いこうしています。
実行してみる
では実行してみます。
こんな感じで各種ボタンの処理が動いて、ライフゲームのルールに則りセルの状態遷移が確認できます。
Redux で管理されたセルの状態に応じて各セルの色が変わっています。スタート、ストップもうまく動いています。
以上。
コメントを書く