今年の応用情報でライフゲームが出てきて
ライフゲーム作りたいなーって思ったので
React + Reduxの勉強ついでに作ってみました。
(完成と呼ぶにはあまりに拙い出来ですが・・・
ライフゲームとは
ライフゲーム (Conway's Game of Life[1]) は1970年にイギリスの数学者ジョン・ホートン・コンウェイ (John Horton Conway) が考案した生命の誕生、進化、淘汰などのプロセスを簡易的なモデルで再現したシミュレーションゲームである。単純なルールでその模様の変化を楽しめるため、パズルの要素を持っている。
今回はwikiにもある基本的なルールで実装していきます
誕生
死んでいるセルに隣接する生きたセルがちょうど3つあれば、次の世代が誕生する。
生存
生きているセルに隣接する生きたセルが2つか3つならば、次の世代でも生存する。
過疎
生きているセルに隣接する生きたセルが1つ以下ならば、過疎により死滅する。
過密
生きているセルに隣接する生きたセルが4つ以上ならば、過密により死滅する。
Directories
ディレクトリは以下のようになっています。
~/t/react-redux-lifegame ❯❯❯ tree src src
├── actions
│ └── index.js
├── components
│ ├── App.jsx
│ ├── Board.jsx
│ └── Cell.jsx
├── containers
│ └── LifeGame.js
├── reducers
│ ├── cells.js
│ ├── generation.js
│ └── index.js
├── app.js
└── index.html
action
, component
,container
,reducer
の4要素に分けるのが
コツっぽいのでそれっぽく分けたつもり。。。
ポイント
- action: storeの状態遷移を指示する。
- component: view(react)の部分。stateに合わせた見た目を定義する
- container: react-reduxの結合部分。reactのstateとreduxのstore、dispatcher(actionを発行するやつ)をマッピングする
- reducer: 発行されたactionを見てstoreの状態遷移を行う。副作用を持たない
componentとcontainerの切り分けが意外に困ったりする。。。
Main
app.js
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import lifegame from './reducers'
import App from './compoents/App'
const initialState = {
cells: [
[0,1,0,0,0],
[1,1,0,1,0],
[0,0,1,0,0],
[0,0,0,0,0],
[0,0,0,1,0]
],
generation:0
}
const store = createStore(lifegame, initialState)
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('container')
)
今回storeは以下の2つを定義
- cells:各セルの状態を格納
- generations: 現在の世代数を格納
ポイント
- アプリケーションに必要な状態はここのstoreのみで管理する
-
createStore
でreducerとstoreを対応させる
Actions
index.js
export function nextGeneration(){
return { type:'NEXT_GENERATION'}
}
ここでは次世代への遷移を指示するアクションのみ定義
ポイント
- アクションでは状態遷移の種類(type)と状態遷移に必要な値(idとかformから渡された値とか)を渡す
- ここではどんな状態遷移を行うかの指示のみでstoreの更新等は行わない
Components
App.jsx
import React from 'react'
import LifeGame from '../containers/LifeGame'
const App = () => (
<LifeGame />
)
export default App
Root Component
ここではLifGame Container を呼び出している
Board.jsx
import React from 'react'
import Cell from '../components/Cell'
import {nextGeneration} from '../actions'
class Board extends React.Component{
componentDidMount() {
const {dispatch} = this.props
this.timer = setInterval(() => dispatch(nextGeneration())
, 1000);
}
componentWillUnmount() {
clearInterval(this.timer);
}
render(){
const {cells, generation} = this.props
return (
<div>
<span>Generaion: {generation}</span>
<table>
<tbody>{
cells.map((line, i) =>
<tr key={i}>{
line.map((cell,i) =>
<Cell key={i} isLive={cell} />
)
}</tr>
)
}</tbody>
</table>
</div>
)
}
}
export default Board
ライフゲームの盤面を表示するコンポーネント
ポイント
-
componentDidMount
はコンポーネントが描画されたあとに呼び出される。
ここで1秒ごとにnextGenerationアクションを発行するようにしてる -
componentWillUnMount
はコンポーネントが破棄されるときに呼び出される。
ここでタイマーを破棄しておかないと次のコンポーネント描画時のタイマーと被ってしまう? -
this.props
に入ってくる値はcontainerで決める -
基本的にコンポーネントはRedux側に依存しない・・・??
世代数の表示もこのコンポーネント内でやってしまっているが、分けたほうがいい気がする。。
cell.jsx
import React from 'react'
let Cell = ({isLive}) => (
<td>{isLive?'□':'■'}</td>
)
export default Cell
各セルの見た目。
もうちょっと綺麗にしたい。。。
Containers
LifeGame.js
import { connect } from 'react-redux'
import Board from '../components/Board'
function mapStateToProps(state){
return{ cells:state.cells, generation:state.generation}
}
const LifeGame = connect(mapStateToProps)(Board)
export default LifeGame
reactとreduxの結合部分
mapStateToPropsでComponentに渡す状態を決めてあげる
ポイント
- react-redux#connect でreactとreduxの結合を行う
- connectの第1引数にはマッピングを定義した関数, 第2引数には結合するコンポーネントを渡す
Reducers
index.js
import { combineReducers } from 'redux'
import cells from './cells'
import generation from './generation'
const lifegame = combineReducers({
cells
,generation
})
export default lifegame
Reducerを一つのファイルに書くと多くなってしまうので、
状態ごとに分けてここで一つのReducerとして結合する
cell.js
function cells(state=[], action) {
switch(action.type) {
case 'NEXT_GENERATION':
return _updateCells(state)
default:
return state
}
}
function _updateCells(cells=[]){
var cells_tmp = cells
for(var y = 0; y < cells.length; y++){
for(var x = 0; x < cells[y].length; x++){
var cnt = _countLiveCell(cells, x, y)
if(cnt < 2 || cnt >= 4){
cells_tmp[y][x] = 0
}else{
cells_tmp[y][x] = 1
}
}
}
return cells_tmp
}
function _countLiveCell(cells, x, y){
var cntLiveCell = 0
for(var i = -1; i <= 1; i++){
for(var j = -1; j <= 1; j++){
if(i === 0 && j === 0 ){
continue
}
var _y = _checkRange(y + i, 0, cells.length - 1)
var _x = _checkRange(x + j, 0, cells[y].length - 1)
if(cells[_y][_x] === 1){
cntLiveCell++
}
}
}
return cntLiveCell
}
function _checkRange(num, min=0, max=0){
if(num < min){
return min
}
if(num > max){
return max
}
return num
}
export default cells
セルの状態遷移を担うreducer
actionのtypeを見て行う処理をスイッチさせるように書くっぽい。。
原則渡ってきたaction.typeが不明な場合は元のstateを返すようにする
またreducerは副作用を持たない(=元のstoreの値を更新しない)ようにする
(ただ上のコードは元のstoreの値を更新してしまっているような気もする。。。)
ライフゲームのルール実装部分が汚いので、
もうちょっとES2015の書き方で簡潔に書きたい
generation.js
function generation(state=[], action){
switch(action.type) {
case 'NEXT_GENERATION':
return state + 1
default:
return state
}
}
export default generation
世代数を表すreducer
セルの世代交代と完全に別物になっている。
最初はreducerとactionが分かれている理由がよくわかっていなかったが、
action ⇛ reducer とすることでreducerの責任が限定されるので
実装/テストしやすくしてるんではないかなー
成果物
感想
React も Redux も最初はすごいとっつきにくいですが、
実際にコード書いてみると小さい単位でコードを書いていけるので
よさ気な感じですね。
特にデータバインディングなのでロジック部分はデータをどのように
いじるかのみに集中すればよいので、すごく実装しやすいと思いました。
今回のコードはGitにあげています。
https://github.com/tamanugi/react-redux-lifegame
また実装の拙い部分や考え方でおかしい部分などあればご指摘もらえますと、
嬉しいです。