Reactで神経衰弱アプリ作ってみた（コンポーネント作成編）

## 企画概要

今アツいReactを使って、みんなでアプリを作ってみよう！という思いから始まった神経衰弱アプリ作成企画…

全6回のうち、今回は第5回の***コンポーネント作成***編です！



関連記事は以下です
興味のある方は是非シリーズで読んでみてください

- [決起編](https://qiita.com/ntc-suzuki/items/713ee53825e833ab2a19)
- [フロント環境構築編](https://qiita.com/ntc-arai/private/06344ef8522205c9936d)
- フロントエンド編
- コンポーネント作成編 ★本記事
- [バックエンド編](https://qiita.com/ntc-suzuki/items/34eb7ff436326b2c88b7)
- [リファクタリング編](https://qiita.com/ntc-saito/private/6af683fd7ee936c23ac2)

## コンポーネント作成編

アプリのメイン機能であるカードをめくって一致するか判定するロジックなどは完成しました。

ここからはゲームらしくするために以下の機能を作成します！

- 残り時間のカウントダウン
- ゲームオーバーまたはクリアのモーダル表示（おまけで「もう一度遊ぶ」機能）

 ## カウントダウンタイマー機能

まずは***カウントダウンタイマー***の実装からです。

といっても、ここでしたことは参考サイトのコードを真似して書いてみただけです。

setIntervalでカウントダウンしていってますね。

業務の合間にやってる開発なので時短は大事です。


```react:src\hooks\useCountDownTimer.ts
import { useEffect } from "react";

const useCountDownTimer = (
  countTime: number | null,
  setCountTime: (arg0: number) => void
) => {
  useEffect(() => {
    const countDownInterval = setInterval(() => {
      if (countTime === 0) {
        clearInterval(countDownInterval);
      }
      if (countTime && countTime > 0) {
        setCountTime(countTime - 1);
      }
    }, 1000);
    return () => {
      clearInterval(countDownInterval);
    };
  }, [countTime]);
};

export { useCountDownTimer };
```

```react:src\pages\game.tsx (タイマー部分のみ抜粋)

import React, { useState } from "react";
import { useCountDownTimer } from "../hooks/useCountDownTimer";

// タイマーの時間
const TIMEOUT = 60;

const Board: React.FC = () => {
// タイマーの設定
  const [countTime, setCountTime] = useState<number>(TIMEOUT);
  useCountDownTimer(countTime, setCountTime);

  return (
    <div>
      <p
        style={{
          display: "flex",
          justifyContent: "center",
        }}
      >
        ゲーム残り時間:
        {countTime % 60}秒{" "}
      </p>
    </div>
  );
};

export default Board;
```
#### 参考
https://zenn.dev/kazuki_tam/scraps/1e2984ea02b838 

## モーダル表示機能 + 「もう一度遊ぶ」機能

さて、次はタイマーが0になったときに「GameOver」、カードが全部そろったときに「Clear」と表示するモーダルを実装します。

こちらも基本は参考サイトを見ながらモーダルを作成しました！

まずは**MaterialUI**と**react-modal**のライブラリを導入します。便利なものはすぐに使いましょう。時短大事（2回目）
```terminal
npm install @mui/material @emotion/react @emotion/styled

npm install --save react-modal
```

必要なライブラリがインストールできたら、とりあえずモーダルを表示してみます。

```react
import { Button } from "@mui/material";
import React, { useState } from "react";
import Modal from "react-modal";

const Board: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);

  return (
    <div>
      <Button
        variant="contained"
        color="primary"
        onClick={() => {
          setModalIsOpen(true);
        }}
      >
        モーダル開く
      </Button>
      <Modal isOpen={modalIsOpen}>モーダルだよ</Modal>
    </div>
  );
};

export default Board;
```

|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3895556/9e675b2b-2715-3583-8d2a-9bea6dcc25ee.png)|
|:-:|
|初期表示|

|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3895556/ed144e73-1f98-6cbf-f95f-ec808e08ddc6.png)|
|:-:|
|「モーダル開く」を押すとモーダルが表示されます（styleを設定していないので見た目が崩れていますが一旦無視）|


### ゲームオーバーモーダル

モーダルが表示できたのでタイマーが0になったときにゲームオーバーのモーダルを出します。

```react
import React, { useEffect, useState } from "react";
import Modal from "react-modal";
import { Button } from "@mui/material";

// タイマーの時間
const TIMEOUT = 60;

const Board: React.FC = () => {
  // タイマーの設定
  const [countTime, setCountTime] = useState<number>(TIMEOUT);
  useCountDownTimer(countTime, setCountTime);

  // モーダルのstyle
  const customStyles = {
    content: {
      top: "20%",
      left: "50%",
      right: "auto",
      bottom: "auto",
      transform: "translate(-50%, -50%)",
    },
  };

  // モーダルの表示設定
  const [gameOverModalIsOpen, setGameOverModalIsOpen] = useState(false);

  useEffect(() => {
    if (countTime === 0) {
      setGameOverModalIsOpen(true);
      return;
    }
    setGameOverModalIsOpen(false);
  }, [countTime]);

  return (
    <div>
     <p
        style={{
          display: "flex",
          justifyContent: "center",
        }}
      >
        ゲーム残り時間:
        {countTime % 60}秒{" "}
      </p>
      <Modal isOpen={gameOverModalIsOpen} style={customStyles}>
        <h2>Game Over!!!</h2>
        <Button
          variant="outlined"
          color="inherit"
          onClick={() => {
            setGameOverModalIsOpen(false);
          }}
        >
          閉じる
        </Button>
      </Modal>
    </div>
  );
};

export default Board;
```

|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3895556/a2a96409-b639-c455-4470-4905fe699326.png)|
|:-:|
|ゲーム残り時間が０秒なのでゲームオーバー|

### クリアモーダル

続いて、クリアモーダルも表示します。

こちらは表示されているカードがすべてペアになったら表示されるように設定します。
```react:
import React, { useEffect, useState } from "react";
import Card from "../components/Card";
import Modal from "react-modal";
import { Button } from "@mui/material";

// カードの枚数
const NUMBEROFCARDS = 24;

// カードの生成関数
const generateCards = (): CardData[] => {
  const values = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"];
  const cards = values.flatMap((value) => [
    { id: Math.random(), value },
    { id: Math.random(), value },
  ]);
  return cards.sort(() => Math.random() - 0.5);
};

const Board: React.FC = () => {
  const [cards, setCards] = useState<CardData[]>(generateCards()); //cardsに、generateCardsで生成した内容を設定
  const [flippedCards, setFlippedCards] = useState<number[]>([]);
  const [matchedCards, setMatchedCards] = useState<number[]>([]);

  // モーダルのstyle
  const customStyles = {
    content: {
      top: "20%",
      left: "50%",
      right: "auto",
      bottom: "auto",
      transform: "translate(-50%, -50%)",
    },
  };

  // モーダルの表示設定
  const [gameOverModalIsOpen, setGameOverModalIsOpen] = useState(false);
  const [clearModalIsOpen, setClearModalIsOpen] = useState(false);
  useEffect(() => {
    if (matchedCards.length === NUMBEROFCARDS) {
      setClearModalIsOpen(true);
      setCountTime(0);
      return;
    }
    if (countTime === 0) {
      setGameOverModalIsOpen(true);
      return;
    }
    setClearModalIsOpen(false);
    setGameOverModalIsOpen(false);
  }, [countTime]);

  return (
    <div>
      <Modal isOpen={clearModalIsOpen} style={customStyles}>
        <h2>Game Clear !</h2>
        <Button
          variant="outlined"
          color="inherit"
          onClick={() => {
            setClearModalIsOpen(false);
          }}
        >
          閉じる
        </Button>
      </Modal>

      <Modal isOpen={gameOverModalIsOpen} style={customStyles}>
        <h2>Game Over!!!</h2>
        <Button
          variant="outlined"
          color="inherit"
          onClick={() => {
            setGameOverModalIsOpen(false);
          }}
        >
          閉じる
        </Button>
      </Modal>
    </div>
  );
};

export default Board;
```
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3895556/0ec1916b-5bcf-06ab-3ca6-6c47f01db9fe.png)|
|:-:|
|見た目はゲームオーバーとほぼ変わらず。こちらはクリアしたことで残り時間が0に更新されている|

### 「もう一回」ボタン

ついでに、「もう一回」ボタンを押したときにゲームの状態を初期化して再度神経衰弱が出来るようにしてみました。

カウントダウンタイマーの初期値を再設定して、カードの生成関数とペアになったカードの配列を初期化しています。
```react
 // 「もう一回」ボタン押下時の処理
  const retryGames = () => {
    setCountTime(TIMEOUT);
    setMatchedCards([]);
    setCards(generateCards());
  };
```
|![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3895556/e06bf956-d0ac-d46a-5e2d-23d39894f01e.gif)|
|:-:|
|「もう一回」ボタンを押下すると再度ゲームが始まる|

これでモーダルが完成しました！

***時間制限***と***リトライ***が出来るようになったことでだいぶゲームらしくなったのではないでしょうか？！


#### 参考

https://tech.asoview.co.jp/entry/2021/12/10/000000 

https://mui.com/material-ui/react-modal/ 





## 完成したソースコード

```react:src\pages\game.tsx

import { Link } from "react-router-dom";
import React, { useEffect, useState } from "react";
import Card from "../components/Card";
import { useCountDownTimer } from "../hooks/useCountDownTimer";
import Modal from "react-modal";
import { Button } from "@mui/material";

// カードのデータ型
interface CardData {
  id: number;
  value: string;
}

// カードの枚数
const NUMBEROFCARDS = 24;
// タイマーの時間
const TIMEOUT = 60;
// カードの生成関数
const generateCards = (): CardData[] => {
  const values = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"];
  const cards = values.flatMap((value) => [
    { id: Math.random(), value },
    { id: Math.random(), value },
  ]);
  return cards.sort(() => Math.random() - 0.5);
};

const Board: React.FC = () => {
  const [cards, setCards] = useState<CardData[]>(generateCards()); //cardsに、generateCardsで生成した内容を設定
  const [flippedCards, setFlippedCards] = useState<number[]>([]);
  const [matchedCards, setMatchedCards] = useState<number[]>([]);

  // モーダルのstyle
  const customStyles = {
    content: {
      top: "20%",
      left: "50%",
      right: "auto",
      bottom: "auto",
      transform: "translate(-50%, -50%)",
    },
  };

  // タイマーの設定
  const [countTime, setCountTime] = useState<number>(TIMEOUT);
  useCountDownTimer(countTime, setCountTime);

  // モーダルの表示設定
  const [gameOverModalIsOpen, setGameOverModalIsOpen] = useState(false);
  const [clearModalIsOpen, setClearModalIsOpen] = useState(false);
  useEffect(() => {
    if (matchedCards.length === NUMBEROFCARDS) {
      setClearModalIsOpen(true);
      setCountTime(0);
      return;
    }
    if (countTime === 0) {
      setGameOverModalIsOpen(true);
      return;
    }
    setClearModalIsOpen(false);
    setGameOverModalIsOpen(false);
  }, [countTime]);

  // 「もう一回」ボタン押下時の処理
  const retryGames = () => {
    setCountTime(TIMEOUT);
    setMatchedCards([]);
    setCards(generateCards());
  };

  const handleCardClick = (id: number) => {
    //揃い済みのカード、めくったカードをクリックしたら何も処理をせず返す。
    if (
      flippedCards.length === 2 ||
      matchedCards.includes(id) ||
      flippedCards.includes(id)
    ) {
      return;
    }

    //めくられたカードのidをFlippedCardsの要素に追加する。
    //1枚目、2枚目の要素がnewFlippedCardsに追加される。
    const newFlippedCards = [...flippedCards, id];
    setFlippedCards(newFlippedCards);

    //2枚めくられたら以下の判定処理を実施
    if (newFlippedCards.length === 2) {
      const [firstId, secondId] = newFlippedCards;
      //カードの配列のなかから、1枚目、2枚目のカードIDと一致するカード情報を検索
      const firstCard = cards.find((card) => card.id === firstId);
      const secondCard = cards.find((card) => card.id === secondId);

      //1枚目と2枚目のカードの内容が一致するか確認。？を使いnull対策。
      //一致したら、matchedCardsにfirstId,secondIDを追加
      if (firstCard?.value === secondCard?.value) {
        setMatchedCards([...matchedCards, firstId, secondId]);
      }

      //1秒後にsetFlippedCardsを実行し、flippedCards状態が空になり、すべてのカードが裏返しに戻る
      setTimeout(() => setFlippedCards([]), 1000);
    }
  };

  // カードを並べるためのレイアウトを設定
  const rows = [];
  for (let i = 0; i < 3; i++) {
    rows.push(
      <div key={i} style={{ display: "flex", justifyContent: "center" }}>
        {cards.slice(i * 8, (i + 1) * 8).map((card) => (
          <Card
            key={card.id}
            id={card.id}
            value={card.value}
            // 以下評価結果がtrueだと裏になる。
            isFlipped={
              flippedCards.includes(card.id) || matchedCards.includes(card.id)
            }
            onClick={handleCardClick}
          />
        ))}
      </div>
    );
  }

  return (
    <div
      style={{
        marginTop: "50px",
      }}
    >
      <p
        style={{
          display: "flex",
          justifyContent: "center",
        }}
      >
        ゲーム残り時間:
        {countTime % 60}秒{" "}
      </p>
      {rows}
      <div
        style={{
          marginTop: "50px",
          display: "flex",
          justifyContent: "center",
        }}
      >
        <Link to="/">
          {" "}
          <Button variant="outlined" sx={{ justifyContent: "center" }}>
            HOMEへ戻る
          </Button>
        </Link>
      </div>

      <Modal isOpen={clearModalIsOpen} style={customStyles}>
        <h2>Game Clear !</h2>
        <Button
          variant="outlined"
          color="primary"
          onClick={() => {
            setClearModalIsOpen(false);
            retryGames();
          }}
        >
          もう一回
        </Button>{" "}
        <Button
          variant="outlined"
          color="inherit"
          onClick={() => {
            setClearModalIsOpen(false);
          }}
        >
          閉じる
        </Button>
      </Modal>

      <Modal isOpen={gameOverModalIsOpen} style={customStyles}>
        <h2>Game Over!!!</h2>
        <Button
          variant="outlined"
          color="primary"
          onClick={() => {
            setGameOverModalIsOpen(false);
            retryGames();
          }}
        >
          もう一回
        </Button>{" "}
        <Button
          variant="outlined"
          color="inherit"
          onClick={() => {
            setGameOverModalIsOpen(false);
          }}
        >
          閉じる
        </Button>
      </Modal>
    </div>
  );
};

export default Board;
```