見出し画像

RxJSを取り入れてテトリスを組んでみた

この記事はjig.jp Advent Calendar 2023の12月8日(金)の記事です。


はじめに

jig.jp Advent Calendar 2023 12/8(金)を担当する藤井です。よろしくお願いします。
みなさん RxJSというライブラリは知っていますでしょうか?
個人的に好きなライブラリでまだまだ使いこなせてはいないですが、楽しんで使っています。
RxJSについて公式Docには以下のような記載があります。

Think of RxJS as Lodash for events.

つまりRxJSは様々なイベントを扱うLodashのようなものといった感じです。
RxJSの基本的な使い方としては「値を流すObservableというオブジェクトを購読(subscribe)して値が流れてきた時の処理を書く」という形になります。

// Observable型のオブジェクトを作成
// このObservable型のオブジェクトを作成するメソッドはRxJS側からたくさん用意されています。
// $はObservable型の変数であることがわかるようにつけています
const observable$ = new Observable(...)

// subscribeする(購読する)
observable$.subscribe(() => {
  // observableから値が流れてきた時の処理を書く
})

これによって、クリックイベントが起こった時やある特定の処理が終了した時など様々なタイミングで値を流して、あらかじめ購読していた箇所で処理を実行することができます。
以前、Learn RxJSというサイトの「Tetris Game」の記事を見たときに、subscribe()関数を一つ実行するだけで、まるでピタゴラスイッチのように処理が連鎖しゲームが動いていくのが面白かったので、今回のアドベントカレンダーではRxJSを用いてテトリスを組んでみようと思いました。
実際に組み始めると「Tetris Game」の記事のように、1からRxJSで組み立てるのは自分にはまだ難しかったので(特に座標やテトリミノ情報の管理...)、こちらのサイトのテトリス実装にRxJSを取り入れる形で組んでみました。

テトリスはこちらで試すことができます。

ソースコード

ゲーム画面

RxJSを取り入れたところ

RxJSは「様々なイベントに対してあらかじめ登録されている処理を行う」といったことに長けているため、テトリスを実装する際の以下の部分でRxJSを取り入れました。

  • DOMが読み込まれたイベントに反応して、ゲームをスタートさせる

  • 一定時間ごとに発火するイベントに反応して、テトリミノを落下させる

  • キーボードイベントに反応して、それぞれのキーに対応する処理を行う

DOMが読み込まれたイベントに反応して、ゲームをスタートさせる

ゲームの根幹処理をgameInterval$というObservable型の変数にまとめて、DOMが読み込まれたらgameInterval$.subscribe()してゲームをスタートするようにしました。

ゲームがスタートすると、mergeというオペレータを使用して

  • 一定時間間隔でテトリミノを落とす処理

  • キーボードイベントリスナーの登録

  • タイマーをスタートさせる処理

の3つを実行させています。

以下は実際のコードになります。
大きな括りで分けられていますが、つなげるとgameInterval$がsubscribeされたタイミングでstartTetris$というObservableから値(undefined)が流れて、処理が順に実行されていく形になります。

RxJSのいいところですが、ゲーム全体の処理をObservableで扱うことで、処理の流れが1方向(上から下)になるので処理が追いやすいです。

/** ゲーム開始 */
const startTetris$ = of(undefined);  // このof()というオペレータは購読されたタイミングで引数の値を流す

/** ゲームの初期化 */
const initializedGame$ = startTetris$.pipe(
  // ボードの初期化
  initializeBoard$,
  // テトリミノをセット
  setMino$,
  // 最初のテトリミノを描画
  draw$,
  // 次のテトリミノの描画
  nextMinoDraw$,
);

/** ゲーム処理全体 */
const gameInterval$ = initializedGame$.pipe(
  switchMap(() =>
    merge(
      // ゲームの定期処理を実行
      interval(SPEED).pipe(
        // テトリミノを落とす
        switchMap(() => minoMoveDown$),
        // ゲームオーバーの時は定期処理を停止
        takeUntil(isGameOver$),
      ),
      // キーボードイベントを処理
      keyboardEventListener$.pipe(
        // ゲームオーバーの時はイベントリスナーを停止
        takeUntil(isGameOver$),
      ),
      // 制限時間のタイマーをセット
      startTimer$,
    )
  ),
  // 描画処理
  draw$,
);

// DOMが読み込まれたらゲームスタート
document.addEventListener("DOMContentLoaded", function () {
  gameInterval$.subscribe();
});

一定時間ごとに発火するイベントに反応して、テトリミノを落下させる

上記のコードではintervalというオペレータを用いて、定期更新処理を行なっています。

intervalはwindow.setIntervalのように一定時間間隔で値を流してくれるため、あらかじめ値が流れてきた時にしてほしい処理、今回の場合はテトリミノを1マス落下させる処理を登録しておくことで簡単に一定時間間隔でテトリミノを落下させることができます。

takeUntilというオペレータを挟んでおくことで、引数に入れてあるObservableから値が流れてきた時に購読を終了させることができるため、ゲームオーバーになったら定期処理が止まるようになっています。

キーボードイベントに反応して、それぞれのキーに対応する処理を行う

キーボードのイベントはfromEventオペレータを使用してキャッチして、filterオペレータを使うことで押されたキーによってそれぞれの処理を行うことが簡単に実装できます。

iifオペレータはif文のような使い方ができるため、キーボードイベントをキャッチした時にその方向に移動や回転ができるか判断して、できるなら移動や回転するといった処理になっています。

以下は実際のコードになります。

/** キーボードイベントリスナー */
const keyboardEvent$ = fromEvent<KeyboardEvent>(document, "keydown").pipe(
  shareReplay(1),
);

/** 「↓」キーを押された時の処理 */
const keyDown$ = keyboardEvent$.pipe(
  filter((e: KeyboardEvent) => e.code === "ArrowDown"),
).pipe(
  switchMap(() => canMinoMove$(0, 1)),
  switchMap((data) =>
    iif(
      () => data,
      moveDown$,
      NEVER,
    )
  ),
);

/** 「←」キーを押された時の処理 */
const keyLeft$ = keyboardEvent$.pipe(
  filter((e: KeyboardEvent) => e.code === "ArrowLeft"),
).pipe(
  switchMap(() => canMinoMove$(-1, 0)),
  switchMap((data) =>
    iif(
      () => data,
      moveLeft$,
      NEVER,
    )
  ),
);

/** 「→」キーを押された時の処理 */
const keyRight$ = keyboardEvent$.pipe(
  filter((e: KeyboardEvent) => e.code === "ArrowRight"),
).pipe(
  switchMap(() => canMinoMove$(1, 0)),
  switchMap((data) =>
    iif(
      () => data,
      moveRight$,
      NEVER,
    )
  ),
);

/** 「Space」キーを押された時の処理 */
const keySpace$ = keyboardEvent$.pipe(
  filter((e: KeyboardEvent) => e.code === "Space"),
).pipe(
  createRotateMino$,
);

/** 「Escape」キーを押された時の処理 */
const keyEscape$ = keyboardEvent$.pipe(
  filter((e: KeyboardEvent) => e.code === "Escape"),
).pipe(
  stockOrExchangeMino$,
);

/** キーボードのイベントリスナーをまとめた変数 */
const keyboardEventListener$ = merge(
  keyDown$,
  keyLeft$,
  keyRight$,
  keySpace$,
  keyEscape$,
);

まとめ

今回はテトリスをRxJSを用いて実装してみました。
RxJSを用いて実装することで、処理の流れが1方向で読みやすくなり良さそうでした。
しかし、ここで紹介していない部分のコードではtapオペレータを多用しすぎていることや、グローバル変数を直接参照して直接書き換えていることが気になるので、後日その点を修正していきたいです。

最後に、読んでくださってありがとうございました!ぜひテトリスで遊んでみてください!

この記事が気に入ったらサポートをしてみませんか?