Advent
reactjs
React
tetris
redux-saga
React #1Day 11

Redux-Sagaでテトリス風ゲームを実装して学んだこと

はじめに

React #1 Advent Calendar 2017の11日目です。

Reduxのミドルウェア「Redux-saga」で「やや複雑なリアルタイムゲーム」としてテトリス風のゲームの基本部分を開発し、それを通じて学んだこと・感じたことを紹介します。

画面例

まずは実装したテトリス風ゲームの画面例。矢印キーの左右下でピースを動かし、z,xで回転です。

sagaris.gif

sagaris2.mov.gif

実際にこちらから遊べます。ソースコードはこちらから。

テトリス風ゲーム実装を通じて学んだこと

Redux-Sagaの効用として良く言われるのは、「作用を分離する」とか「テストを簡単にする」ということです。それ以外に、今回、ゲームの実装を通じて思ったこと、思いついたことをつらつらと書いていきたいと思います。

  • 利点1. ビューからビジネスロジックを分離する
  • 利点2. ロジックとロジックの間を疎結合にする
  • 利点3. ロジックフローの明確化

それぞれ説明します。

利点1. ビューからビジネスロジックを分離する

React単独だと分離できない

コンポーネント指向のビューライブラリであるReactにおいて、ロジックは起点としてonClickなどに設定するコールバック関数、もしくはコールバックから呼び出す関数やメソッドで実現されます。このとき、依存性は以下のようになります。

alt

ビューはロジックに依存します。たとえば、イベントが発生したときに実行されるロジックの処理が存在するか否か、処理の個数、メソッド名や引数の変更などに応じてビューを変更しなければならない可能性があります。1

Reduxと組合わせれば…

Reduxは、ビューから状態を分離します。さらにビューは「Actionをdispatchする」という建前なので、依存性は以下のようになります。

alt

Actionを介して依存性の逆転を行うことができ、ビューはActionのみに依存するようになります…。
…と言ったか? それは嘘だ。

Redux-thunk(その他)ではビュー→ロジックの依存性を排除できない

Redux-thunkでは「Actionをdispatchするかわりに関数をdispatchする」というだけのものなので、「どの処理をすべきか考え、どこに処理があるかを見つけて取得するか記述して、dispatchに渡す」という責任がビューにあります。ロジックが記述されているのがメソッドであろうがアロー関数であろうが、Action Creatorで隠蔽しても同じことです。

Redux-thunkに限らず、ロジックをコンポーネントのメソッドに書く場合でも、mapDispathchToPropsに書く場合でも、ImmutableJSのRecordのメソッドに書く場合でも同じです。2

もちろん、ロジックを変更したときにビューへの影響が最小限にするための努力は可能でしょう。たとえば、「将来に変更が必要にならないように、ロジックの関数名やシグネチャを十分事前に検討する」などです。しかしながら、予測できないことは発生するのが常であり、限界があると知るべきでしょう。

Redux-Sagaで実現されるビュー→ロジックへの依存の完全排除

Redux-Sagaでは、Actionはかならずシンプルなデータであり、ビューが発行するActionに対してどんな処理が実行されるか、そもそも処理が実行されるかどうかすら、ビューの関知することではありません。このことがビューの債務を明確かつ単純にしてくれ、また試験を容易にしてくれることは明らかです。
Redux-Sagaの効用の一つは、以下のような依存性の逆転をきちんと実現することです。

alt

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アクション(そのUIイベントを最小完全に表現するデータ)を発行すること」に縮退するのです。

上記のアーキテクチャのポイントの一つは、ActionをUser/System/Reducer Actionの3種類にわけるというものですが、今回、ちょっとアレンジ3して、以下のようにしてみました。

  • UIアクション…ビューのみが発行し、Sagaのみがtakeする。
  • システムアクション…ビューあるいはSagaが発行し、Sagaのみがtakeする。
  • Reducerアクション…Sagaのみが発行し、Reducerのみが受けとる。

これに従って今回実装したテトリス風ゲームのAction一覧は以下のとおりです。

種別 アクション
UIアクション UI_BUTTON_CLICKED
UI_KEY_DOWN
UI_MODAL_OPEN
UI_MODAL_OK
UI_MODAL_CANCEL
システムアクション SYS_TIME_TICK
SYS_GAME_START
SYS_GAME_QUIT
SYS_GAME_OVER
SYS_FIX_DOWN_PIECE
Reducerアクション UPDATE_CELL
SET_BOARD
SET_CURRENT_PIECE
SET_GAME_RUNNING
SET_GAME_PAUSING
SET_MODAL
SET_SCORE
ADD_SCORE

イメージわきますでしょうか。システムアクションはロジックにかかわる、アプリケーションで発生する意味的なレベルのイベントです。
Reducerアクションのreducerでのハンドリングは本当に機械的なstoreの更新のみです。ロジックは全く・ほとんど含みません。もし含むとしたらバリデーションみたいなものでしょうか。

利点2. ロジックとロジックの間を疎結合にする

Redux-Sagaではコンカレントに動作する複数のタスクを記述することができます。

alt
(「redux-sagaで非同期処理と戦う」より引用)

並行動作するタスクととタスクの間の連携や連動、同期のキック処理は、ビューとタスクと同様に、シンプルデータとしてのActionのみが取り持ちます。(ちなみにこのときのActionは、go言語のCSPチャンネルのように振る舞っていると言えるんじゃないかと思います)

ビューとタスクの依存性を除去したのと同じように、タスク間において、タスクを追加したり、変更したりすることが他のタスクへの影響を及ぼさないようにすることができます4

書き方にもよるのですが、たとえば今回のテトリス風ゲームで言えば、ゲームの起動画面の処理と、実際のゲームの処理を分けることができます。

以下は起動面側のタスク「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で以下のような処理を実行しています。

  1. 新しい落下テトロミノのピースを乱数で決定する。
  2. ピースをボードの初期位置に置けないならGAME_OVERシステムイベントを発行する
  3. ピースを「現在のピース」に設定する
  4. 「キーの入力、一定時間経過、現在のピースが一番下まで落下して固着」のいずれかが発生するまで待つ
  5. 発生したイベントが「現在のピースが一番下まで落下して固着」のときスコアを増加
  6. 現在のピースが一番下まで落下したが未だ固着していないなら「余裕時間」タスクをバックグラウンド起動。
  7. 余裕時間タスクはカウントダウンして、余裕時間が終了すると「現在のピースが一番下まで落下して固着」イベントを発行
  8. 入力キーが'Q'や'P'のときポーズ処理や、終了の確認モーダルダイアログ処理
  9. 一定時間が経過したか、↓キーが入力されたときピースを下方移動。
  10. その他の方向キーが入力されたときピースをその方向に移動

該当部分のコードは以下のとおりです。

saga.js
  :
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のコードが本当に読みやすいかにも疑問を持つべきですが、可読性の底ぬけ崩壊を避け、踏ん張れるかな、という印象です。

業務用アプリでも役にたつのか?

もっとも、リアルタイムゲームではなく、一般的な業務用アプリで上記ぐらいの制御が求められるのか、という疑問もあるでしょう。この疑問については、まずは複雑なものを読み易く書き下せるなら、より簡単なものを書くことにも恩恵がある、ということが言えます。
また、以下のようなケースでは業務用アプリでも恩恵があるでしょう。

  • ウィザード形式のフォーム入力など、ステップ・分岐で進行する処理。
  • サーバプッシュやサーバ状況変化への対応
    • たとえば、編集している帳票が他の人によって削除されたり、チャットしている相手がログアウトする
    • たとえば「予約」のような刻々と状況が変化する対象に対する処理
    • たとえば監視系のコンソール
    • 電波状況の変化によってオフライン・オンラインになったときのデータ更新、オフラインになっていた他者がオンラインになることによる更新
  • IDEのような複数ペインで同時進行するUI処理。例えばVSCodeのコンソール出力やバックグラウンドでのエラーチェック、など。
  • 比較的複雑な処理
    • インクリメンタルサーチなどに伴い、非同期的な複数の値の取得、処理結果を整列させる、キャンセル、キャッシュ、スロットル、デバウンス、およびそれらの組合せ
  • 独立性の高いコンポーネント群としてアプリケーションを構成する。 利点3で示した利点により、アプリケーションをサブシステムとして(おそらくNPMとして)分離分割する戦略に貢献することができます。

3D化してみよう

「ReduxおよびRedux-Sagaを使用することで、ビューとロジック・状態が完全分離できる」というのが本記事の主張です。その証明として、Sagaによるタスク定義とredux部分に1行たりとも変更を加えず、ビューだけをReactVRに置き変えてみます。

tetrominovr.mov.gif

いともたやすくVRゲーム化できました5こちらからゲームをプレイできます。カードボードなどVRゴーグルがあれば没入できるはずです。ソースコードはこちらから。

まとめ

  • Redux-SagaはReduxのキラーアプリケーション。このためだけにReduxを使うということもあり。
  • 速度もこのぐらいなら十分だった。
  • ビュー・ロジック間、ロジック間の疎結合性が特によいところ。
  • 「思いっきり命令型」だと? わーっわーっわーっ。聞こえない聞こえない(耳を塞いで)
  • requestAnimationFrameをSagaから実行してTIME_TICKアクションを発行してますが、副作用なので本当はモックするとかせんといかん

おまけ

去年のRedux Advent Calenderで「Obelisk.jsとReduxで3Dテトリス「Oberis」を作ってみた
」という記事があることに気づきました。3Dまでまるかぶりや。普遍性があるということで。

参考、ReactVRの記事


  1. ここで言う「ビュー」はReactコンポーネントを組合せて作られたビュー全体です。コールバック関数をpropsで外部から供給すれば、そのコンポーネントはロジックから独立になりますが、それはpropsを供給する側のコンポーネントに責任が移動したにすぎません。 

  2. redux-promise~やredux-observable(redux-observableでは同様にアクションを監視できるので削除。)~でも同じと思うが良く知らないので自信なし。 

  3. 元のアーキテクチャではシステムアクションはビューが発行しSagaでは発行しない。しかしたとえばrequestAnimatonFrameなどSagaで管理した方がやりやすいものがあり、また「タスク間の疎結合」の実現のためにも有用なので、システムアクションはSagaでも発行するようにした。 

  4. もちろん直接fork作用でタスクをバックグラウンド起動したりするとこの疎結合性は失なわれる。 

  5. react-routerの組込みなど未完成です。