👏

正規表現学習ゲーム「Regex Hunting」の開発方法について、ざっくりまとめてみた

2022/04/11に公開

1.この記事をなぜ書いたか

最近、正規表現学習ゲーム「Regex Hunting」を作りました。
(※) 端末はPC、ブラウザはChromeが推奨です。
https://www.regex-hunting.com/
https://github.com/yukiHaga/regex-hunting


Regex Huntingのフロント部分はReact, TypeScriptで作りました。
そのため、Reactでブラウザゲームを作りたい人の力に少しでもなれれば良いなと思い、開発方法を記事にしました。

記事内の間違いやアドバイス等ありましたら、コメント頂けると嬉しいです。

https://qiita.com/yukiHaga/items/8ef2051036e126eb00c6

2.注意点

3.どのようなロジックでゲームが進行しているのか?

ゲームを作りたい場合、どのようなロジックでゲームを進行させれば良いのだろうと悩むと思います。私も、「どのようなロジックを組めば、ゲームが開始して、進行して、終了するのだろう」と悩みました。本サービスの「ログインしていないユーザーがゲームプレイした場合のロジック」は、以下のように実装されています。

しかし、競技プログラミングやアルゴリズムの学習経験が全くない私は、このようなロジックを最初から思いつくことができませんでした。そのため、このロジックを作るために、以下の2点を実施しました。

3.1. とにかく紙に書きまくる

誰かは忘れましたが、著名な建築家が 「紙に書くことによってアイデアがさらに湧いてくる」 と言っていました。私も同感です。確かに、notion等に書くのは便利です。しかし、アイデアを考える場合、もっと直感的に書ける媒体(紙とかホワイトボード)のが良いと思います。

3.2. タスクを細く分解して具体的にする

ゲームが進行する間に何が起こるのかをもっと細かく分解して具体的にします。
本サービスの場合、ゲームが進行する間に、以下のようなことが起きます。

  1. 問題が表示される
  2. ユーザーが正規表現を入力する
  3. 入力した正規表現が正しかったらモンスターにダメージを与える

もっと細かく分解することも可能ですが、大まかに書くとこのような流れです。初めから全ての条件をカバーしなくても良いと個人的には感じます。段階を踏んでロジックに磨きを掛ければ良いと思います。

以上の「2.1 とにかく紙に書きまくる」と「2.2 タスクを細く分解して具体的にする」を実践したノートを以下に示します。(殴り書きです)

開発する過程でロジックを変更したりして、ようやくロジックを完成させることができました。

4.ゲーム画面をコンポーネント指向で開発する

ロジックをコンポーネントに落とし込む前に、ゲーム画面をコンポーネント指向で開発します。
先にゲーム画面をコンポーネント指向で開発することによって、必要なコンポーネントを明確にできます。その結果、ロジックをどのコンポーネントに落とし込めば良いか考えやすくなります。
「ゲーム画面がどのようなコンポーネントで構成されているか?」を考えるのは大変でした。そのため、まずは紙に書くことから始めました。

開発する際は、Gamesという親コンポーネントにこれらの子コンポーネントを書きました。

5.ロジックをコンポーネントに落とし込んでみる

3.どのようなロジックでゲームが進行しているのか?で作成したロジックを、4つのコンポーネントに落とし込みました。4つのコンポーネントの関係図を以下に示します。

この図の表す内容は至ってシンプルです。親コンポーネントにgameStateというstateが定義されていて、子コンポーネント内部でそのstateを更新しているだけです。子コンポーネント内部で親コンポーネントのstateを更新することによって、親コンポーネントは再レンダリングされます。親コンポーネントが再レンダリングされることによって、子コンポーネントも再レンダリングされます。その結果、画面の内容が変化します。このような構造にすることによって、1つのstateで複数の子コンポーネントの挙動を管理できます。

各コンポーネントの概要を以下で説明します。

Games(子コンポーネントの挙動やゲーム進行を管理・API通信を担当)

Gamesには、gameStateというstateが定義されています。このstateのプロパティの値によって、子コンポーネントの挙動やゲーム進行を管理しています。gameStateには、「現在の問題を表すプロパティ」や「マッチした文字列を表すプロパティ」等、ゲームを進行する上で欠かせないプロパティが大量に定義されています。また、ゲーム開始時、Games内でAPIと通信して、問題とモンスターのデータを取得しています。それらのデータは、Games内でgameStateに反映されています。
https://github.com/yukiHaga/regex-hunting/blob/develop/frontend/src/containers/Games.tsx

QuestionBlock(問題の表示/更新とゲームの終了判定を担当)

QuestionBlockの基本的な役割は、gameStateに定義されている現在の問題を表示することです。
そして、現在の問題が終了した場合、gameStateに定義されている現在の問題を、次の問題に更新します。また、正解や不正解の問題数が特定の数に到達した場合、gameStateに定義してあるgameResultプロパティを更新して、ゲームを終了させます。

https://github.com/yukiHaga/regex-hunting/blob/develop/frontend/src/components/Games/QuestionBlock.tsx

CodeBlock(入力全般と正誤判定を担当)

CodeBlock内のuseEffectに、ユーザーのキーボード入力を処理する3つのイベントリスナーが定義されています。本サービスでは、Reactが用意しているイベントハンドラが上手く機能せず、苦肉の策として、addEventListenerを使用しました。しかし、推奨はされていないので、自分のサービスで使用する場合は注意が必要です。ユーザーが入力した正規表現は、handleEnter関数によって正誤判定されます。もし、ユーザーが入力した正規表現が正しい場合、handleEnter関数内でgameStateが更新されます。gameStateが更新されることによって、モンスターへのダメージ・フラッシュメッセージ・マッチした文字列を画面に反映させることができます。

CodeBlock.tsx
  // イベントリスナー
  useEffect(() => {
    if(!gameDescriptionOpen && !clickMetaOpen) {
      // 入力をコントロールするイベントリスナー
      document.addEventListener("keypress", handlekeyPress);

      // バックスペースをコントロールするイベントリスナー
      document.addEventListener("keydown", handleBackSpace);

      // エンターキーをコントロールするイベントリスナー
      document.addEventListener("keydown", handleEnter);

      // アンマウント時の処理をここに書く
      // イベントを消すクリーンアップ関数を返す
      return () => {
        document.removeEventListener("keypress", handlekeyPress);
        document.removeEventListener("keydown", handleBackSpace);
        document.removeEventListener("keydown", handleEnter);
      }
    }
  }, [
    // 省略
  ]);

https://github.com/yukiHaga/regex-hunting/blob/develop/frontend/src/components/Games/CodeBlock.tsx

TimeGage(不正解時の処理を担当)

TimeGage内のGageWrapperに、CSSアニメーションが定義されています。CSSアニメーションによって、画面のTimeゲージが動いています。onAnimationEndはアニメーション終了時のイベントを定義できるイベントハンドラです。Timeゲージのアニメーションが終了すると、onAnimationEndで定義したtimeOut関数が実行されます。この関数内でgameStateを更新することによって、ハンターへのダメージやフラッシュメッセージを画面に反映させることができます。

TimeGage.tsx
  // タイムゲージが0になった時に実行される関数
  const timeOut = () => {
    gameState.incorrectQuestions.push({
      question: gameState.questions[0],
      sentenceNum: sentenceNum
    });
    gameState.questions.shift();
    const currentHp = userHp - calculateDamage(monsterAttack, userDefence);
    const audio = new Audio(TackleSound);
    audio.play();
    setGameState((prev) => ({
      ...prev,
      questionJudgement: "incorrect",
      incorrectQuestions: prev.incorrectQuestions,
      userHp: currentHp,
      flashDisplay: true,
      flashTitle: "Bad"
      // 省略
    }));
  };
  
  return (
    <>
    {// 省略 }
            <GageWrapper
              onAnimationEnd={timeOut}
              timeActive={timeActive}
	      // 省略
            />
    {// 省略 }
    </>
  );

https://github.com/yukiHaga/regex-hunting/blob/develop/frontend/src/components/Games/TimeGage.tsx

最初からロジックの全てをコンポーネントに落とし込めたわけではありません。デバッグを繰り返しながら、徐々に落とし込みました。この部分が個人的には一番難しいと感じます。

6.終わり

最後まで見ていただきありがとうございました!
この記事を通して、Reactでブラウザゲームを作りたい人の力に少しでもなれれば幸いです。
もし記事の感想やアドバイス等ございましたら、コメントかTwitterDMしていただけると嬉しいです。

▼Regex Hunting
https://www.regex-hunting.com/

▼Github
https://github.com/yukiHaga/regex-hunting

▼Twitter
https://twitter.com/KvOKJo6SH85w2Q8?ref_src=twsrc^google|twcamp^serp|twgr^author

Discussion