はじめに
React Advent Calender 2017(#1)の11日目です。
Reduxのミドルウェア「Redux-saga」で「やや複雑なリアルタイムゲーム」としてテトリス風のゲームの基本部分を開発し、それを通じて学んだこと・感じたことを紹介します。
画面例
まずは実装したテトリス風ゲームの画面例。
テトリス風ゲーム実装を通じて学んだこと
Redux-Sagaの効用として良く言われるのは、「作用を分離する」とか「テストを簡単にする」ということです。それ以外に、今回、ゲームの実装を通じて思ったこと、思いついたことをつらつらと書いていきたいと思います。
- 利点1. ビューからビジネスロジックを分離する
- 利点2. ロジックとロジックの間を疎結合にする
- 利点3. 複数の無限ループで記述すべきロジックフローを簡明に記述する
それぞれ説明します。
利点1. ビューからビジネスロジックを分離する
React単独だと分離できない
コンポーネント指向のビューライブラリであるReactにおいて、ロジックは起点としてonClickなどに設定するコールバック関数、もしくはコールバックから呼び出す関数やメソッドで実現されます。このとき、依存性は以下のようになります。
ビューはロジックに依存します。たとえば、イベントが発生したときに実行されるロジックの処理が存在するか否か、処理の個数、メソッド名や引数の変更などに応じてビューを変更しなければならない可能性があります。
Reduxと組合わせれば…
Reduxは、ビューから状態を分離します。さらにビューは「Actionをdispatchする」という建前なので、依存性は以下のようになります。
Actionを介して依存性の逆転を行うことができ、ビューはActionのみに依存するようになります…。
…と言ったか? それは嘘だ。
Redux-thunk(その他)ではビュー→ロジックの依存性を排除できない
Redux-thunkでは「Actionをdispatchするかわりに関数をdispatchする」というだけのものなので、「どの処理をすべきか考え、どこに処理があるかを見つけて取得するか記述して、dispatchに渡す」という責任がビューにあります。ロジックが記述されているのがメソッドであろうがアロー関数であろうが、Action Creatorで隠蔽しても同じことです。
Redux-thunkに限らず、ロジックをコンポーネントのメソッドに書く場合でも、mapDispathchToPropsに書く場合でも、ImmutableJSのRecordのメソッドに書く場合でも同じです。1
もちろん、ロジックを変更したときにビューへの影響が最小限にするための努力は可能でしょう。たとえば、「将来に変更が必要にならないように、ロジックの関数名やシグネチャを十分事前に検討する」などです。しかしながら、予測できないことは発生するのが常であり、限界があると知るべきでしょう。
Redux-Sagaで実現されるビュー→ロジックへの依存の完全排除
Redux-Sagaでは、Actionはかならずシンプルなデータであり、ビューが発行するActionに対してどんな処理が実行されるか、そもそも処理が実行されるかどうかすら、ビューの関知することではありません。このことがビューの債務を明確かつ単純にしてくれ、また試験を容易にしてくれることは明らかです。
Redux-Sagaの効用の一つは、以下のような依存性の逆転をきちんと実現することです。
Actionさえ完成していれば、ビューを完成させテストすることができるし、タスク/ロジックについても同様です。
タスクはRedux-Sagaが実現する並行動作の単位であり、Actionの発生を監視し、Actionに応じて処理を実行する存在です(Sagaと呼びます)。
さらに、『実践 Redux Saga』 – React, FLUX, Redux, Redux Saga – // 第21回社内勉強会 #sa_studyで紹介されている「Using React (-Native) with Redux and Redux-Saga. A new proposal?」のアーキテクチャを採用すれば、ビューから発行されるのは「UIイベント」のみとなり、もはや「Action(動作)」という意識も薄れることになります。ビューの債務は、「動作」や「reducerでデータの更新すること」にも関知せず、「HOWや動作を考えず、UIイベントを提起すること、そのイベントを最小完全に表現するデータをアクションとして発行すること」に縮退するのです。
上記のアーキテクチャのポイントの一つは、ActionをUser/System/Reducer Actionの3種類にわけるというものです。今回、ちょっとアレンジ2して、以下のようにしてみました。
- UIアクション…ビューのみが発行し、Sagaのみがtakeする。
- システムアクション…ビューあるいはSagaが発行し、Sagaのみがtakeする。
- Reducerアクション…Sagaのみが発行し、Reducerのみが受けとる。
これに従って今回実装したテトリス風ゲームのAction一覧は以下のとおりです。
種別 | アクション |
---|---|
UIアクション | UI_BUTTON_CLICKED |
UIアクション | UI_KEY_DOWN |
UIアクション | UI_MODAL_OPEN |
UIアクション | UI_MODAL_OK |
UIアクション | UI_MODAL_CANCEL |
システムアクション | SYS_TIME_TICK |
システムアクション | SYS_GAME_START |
システムアクション | SYS_GAME_QUIT |
システムアクション | SYS_GAME_OVER |
システムアクション | SYS_FIX_DOWN_PIECE |
Reducerアクション | UPDATE_CELL |
Reducerアクション | SET_BOARD |
Reducerアクション | SET_CURRENT_PIECE |
Reducerアクション | SET_GAME_RUNNING |
Reducerアクション | SET_GAME_PAUSING |
Reducerアクション | SET_MODAL |
Reducerアクション | SET_SCORE |
Reducerアクション | ADD_SCORE |
イメージわきますでしょうか。システムアクションはロジックにかかわる、アプリケーションで発生する意味的なレベルのイベントです。
Reducerアクションのreducerでのハンドリングは本当に機械的なstoreの更新のみです。ロジックは全く・ほとんど含みません。もし含むとしたらバリデーションみたいなものでしょうか。
利点2. ロジックとロジックの間を疎結合にする
Redux-Sagaではコンカレントに動作する複数のタスクを記述することができます。
(「redux-sagaで非同期処理と戦う」より引用)
並行動作するタスクととタスクの間の連携や連動、同期のキック処理は、ビューとタスクと同様に、シンプルデータとしてのActionのみが取り持ちます。(ちなみにこのときのActionは、go言語のCSPチャンネルのように振る舞っていると言えるんじゃないかと思います)
ビューとタスクの依存性を除去したのと同じように、タスク間において、タスクを追加したり、変更したりすることが他のタスクへの影響を及ぼさないようにすることができます3。
書き方にもよるのですが、たとえば今回のテトリス風ゲームで言えば、ゲームの起動画面の処理と、実際のゲームの処理を分けることができます。
以下は起動面側のタスク「sagas.js」での処理です。
function* demoScreen() {
if (Config.PREDICTABLE_RANDOM) {
Math.seedrandom('sagaris');
}
yield put(push('/'));
while (true) {
// デモ画面
while ((yield take(Types.UI_KEY_DOWN)).payload !== Keys.KEY_S) {
/* do nothinng */
}
// ゲーム開始
yield put(Actions.setGameRunning(true));
yield put(Actions.sysGameStart());
// ゲームオーバー、もしくはQ押下を待つ
const gameResult = yield race({
over: take(Types.SYS_GAME_OVER),
quit: take(Types.SYS_GAME_QUIT),
});
yield put(Actions.setGameRunning(false));
if (gameResult.over) {
// ゲームオーバー画面(確認ダイアログ)表示
yield* gameOver();
}
yield put(push('/'));
}
}
ここでは、"S"キーの入力を待ち、SYS_GAME_STARTイベントを発行し、SYS_GAME_OVERもしくはSYS_GAME_QUITイベントを待ちます。ゲームを実行する別のタスクがSYS_GAME_STARTイベントを待ち受けています。demoScreenは、gameのことを何も知りません。逆も然りです。
利点3. ロジックフローの明確化
Redux-Saga以前、ブラウザ内で動作するJSコードはServiceWorker, WebWorkerを除き本質的には「イベントハンドラの集合」であり、複数のイベントに関連する「一連のロジック」を協調的に実行させる場合、イベントハンドラ間の連携は状態変数(reduxではstateなど)で表現する他はありませんでした。これはBSDソケットのselectを使った通信処理や、協調型イベントドリブンプログラミングと同じ状況で、スレッドが無いので「どこまで処理が進んだか」という情報で状態を共有できないためです。
たとえばウィザード形式の入力フォームで、入力が「どこまで進んだか」を表わすstateを保持するとか、あるいはモーダルダイログを「今開き中です」みたいなstateを定義するとかが典型的ですが、UIの複数箇所で進行中のものがあったりネストしたりすると記述が煩雑になります。
また、Redux-Sagaを使うと「待ち受ける処理」すなわちイベントハンドラやコールバックをプル型、すなわち同期的に「取ってきくる」処理のように記述できます。async/awaitと同じですが、Redux-SagaではGeratorを用いてPromiseに限らず前述のUIアクションやシステムアクションの待ちうけを実行することができます。
たとえば、テトリス風ゲームではタスクpieceFallで以下のような処理を実行しています。
- 新しい落下テトロミノのピースを乱数で決定する。
- ピースをボードの初期位置に置けないならGAME_OVERシステムイベントを発行する
- ピースを「現在のピース」に設定する
- 「キーの入力、一定時間経過、現在のピースが一番下まで落下して固着」のいずれかが発生するまで待つ
- 発生したイベントが「現在のピースが一番下まで落下して固着」のときスコアを増加
- 現在のピースが一番下まで落下したが未だ固着していないなら「余裕時間」タスクをバックグラウンド起動。
- 余裕時間タスクはカウントダウンして、余裕時間が終了すると「現在のピースが一番下まで落下して固着」イベントを発行
- 入力キーが'Q'や'P'のときポーズ処理や、終了の確認モーダルダイアログ処理
- 一定時間が経過したか、↓キーが入力されたときピースを下方移動。
- その他の方向キーが入力されたときピースをその方向に移動
該当部分のコードは以下のとおりです。
:
export function* pieceFall() {
let piece = new Piece(3, 1, Math.floor(Math.random() * 7), 0);
let board = yield select(state => state.main.board);
if (!piece.canPut(board)) {
// トップ位置に置けなければゲームオーバー
yield put(Actions.sysGameOver());
return;
}
yield put(Actions.setCurrentPiece(piece));
let stcTask = null;
while (true) {
const { keyDown, fixDown, timeTick } = yield race({
keyDown: take(Types.UI_KEY_DOWN),
fixDown: take(Types.SYS_FIX_DOWN_PIECE),
timeTick: take(Types.SYS_TIME_TICK),
});
if (fixDown) {
// this piece is fall to bottom or other piece, and fixed
board = piece.setTo(board);
const [newBoard, clearedLines] = Board.clearLines(board);
board = newBoard;
yield put(Actions.setBoard(board));
// line clear bonus
yield put(Actions.addScore(Config.LINES_SCORE[clearedLines]));
break;
}
// 固定時間処理タスクを起動
if (piece.reachedToBottom(board)) {
if (stcTask === null) {
stcTask = yield fork(slackTimeChecker);
}
} else if (stcTask !== null) {
// 固定時間中の操作で底から脱却したときは固定時間を抜ける
yield cancel(stcTask);
stcTask = null;
}
if (keyDown) {
if (keyDown.payload === Keys.KEY_Q) {
yield* gameQuit();
} else if (keyDown.payload === Keys.KEY_P) {
yield* gamePause();
}
}
if (keyDown || (timeTick && timeTick.payload % 60 === 0)) {
// calcurate next piece position & spin
const nextPiece = piece.nextPiece(
(keyDown && keyDown.payload) || Keys.KEY_ARROW_DOWN
);
if (nextPiece.canPut(board)) {
if (
nextPiece !== piece &&
keyDown &&
keyDown.payload === Keys.KEY_ARROW_DOWN
) {
yield put(Actions.addScore(1));
}
piece = nextPiece;
yield put(Actions.setCurrentPiece(piece));
}
}
}
}
1つのピースを生成し、落下しつつ操作され、最後に固着するまでが一連の処理として書かれています。イベントを発生させたり、あるいはイベント発生を待ちあわせたり、「一連の処理」が連携し、データをローカル変数として共有しながら連続していきます。
このような一連の処理を、たとえばasync/awaitなり、Reduxステートなり、あるいはRxJSで読みやすく書けるのかに疑問を持っています。まあもちろん、Redux-Sagaのコードが本当に読みやすいかにも疑問を持つべきですが、可読性の底ぬけ崩壊を避け、踏ん張れるかな、という印象です。
3D化してみよう
さてそろそろ日もかわりますので、Redux部分とSaga部分をそのままに、レンダリング対象をReactVRのビューにしてみます。
いともたやすくVRゲーム化できました4。こちらからゲームをプレイできます。カードボードなどVRゴーグルがあれば没入できるはずです。ソースコードはこちらから。
まとめ
- Redux-SagaはReduxのキラーアプリケーション。このためだけにReduxを使うということもあり。
- 速度もこのぐらいなら十分だった。
- ビュー・ロジック間、ロジック間の疎結合性が特によいところ。
- 「思いっきり命令型」だと? わーっわーっわーっ。聞こえない聞こえない(耳を塞いで)
- requestAnimationFrameをSagaから実行してTIME_TICKアクションを発行してますが、副作用なので本当はモックするとかせんといかん
おまけ
去年のRedux Advent Calenderで「Obelisk.jsとReduxで3Dテトリス「Oberis」を作ってみた
」という記事があることに気づきました。3Dまでまるかぶりや。普遍性があるということで。