【React + Material-UI】数字当てゲームを作ろう！【2020年4月版】

# はじめに

この記事は**「Reactのチュートリアルを終えて、何かを作ってみたい」**という読者を想定しています。
分からない部分が出た場合はReactの[公式ドキュメント](https://ja.reactjs.org/)に立ち返りましょう。

material-uiの[公式ドキュメント](https://material-ui.com/getting-started/usage/)も要チェック

### 前にReact+Material-UIで作ったもの

- [電卓アプリ](https://qiita.com/shinhiro/items/91b48dfdb47eece38604)

# 今回作ったもの

[React Number Guess](https://shintaro-hirose.github.io/react-number-guess/)
ランダムに生成された1~100の数字を推測するゲームです。「60より上ですか?」などの質問をしていき、数字が絞れたら「〇〇ですか？」で正解を出します。右側に予想履歴が表示されます。
<img width="925" alt="スクリーンショット 2020-04-05 13.52.31.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/376127/c6a140b0-7b9a-9792-de07-71c1b2e485ac.png">

[Githubのソースコード](https://github.com/shintaro-hirose/react-number-guess)

# 戦略

Reactコンポーネントには**Material-UI**を使い、保持するstateをReactの**useState**で実装します
また、今回は**useEffect**を使い、読み込み時に1~100のランダムな数字を生成します。

# component, containerの洗い出し

## component

- 数字ボタン: 0~9の数字を入力できるボタンコンポーネント。色はピンクで固定。

<img width="73" alt="スクリーンショット 2020-04-05 17.58.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/376127/449e76de-e925-9622-7a9e-c33396fa10b3.png">

- 大きいボタン: 「より下ですか？」「クリア」「予想する」ボタンなど、数字ボタン以外に使うコンポーネント。色をpropsで指定できる。

<img width="99" alt="スクリーンショット 2020-04-05 17.59.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/376127/758c9f2d-f296-b351-6571-d4034568929f.png">

- ディスプレイ: 現在の質問を表示するコンポーネント

<img width="410" alt="スクリーンショット 2020-04-05 17.59.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/376127/bb653e90-fbd1-5fcf-d125-7b63712ceb66.png">

- 予想履歴: いままでの予想の一覧を表示するコンポーネント

<img width="424" alt="スクリーンショット 2020-04-05 18.00.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/376127/b5898950-a082-b655-c2f0-37e1281a3cb0.png">

- 正解モーダル: 正解したときに表示されるモーダルコンポーネント

<img width="361" alt="スクリーンショット 2020-04-05 18.26.58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/376127/d63a6514-152d-ecda-4d00-4f357f53722d.png">


その他、タイトルと説明はコンポーネントにせず、containerにそのまま書きます。

## container

- NumberGuess: すべてのコンポーネントを集めて表示するコンテナ

# stateの洗い出し

1. inputNumber: 予想に使う数字（int）
2. questionContent: 質問の内容。"より下ですか？"、"より下ですか？"、"ですか？"の3種類(string)
3. history: 質問の履歴が格納された配列(array)。 `[[34,"より下ですか？", "はい"],[17,"より下ですか？", "はい"],[7,"より上ですか？", "はい"]]`　のような2次元配列
4. answer: 答えの数字。はじめにuseEffectでランダムに設定される(int)
5. open: 正解したときにmodalを開くために必要なboolean
6. guessTimes: 質問した回数(int)

# インストール

## create react app

```
npx create-react-app react-calculator
cd react-calculator
```

## @material-ui/core

```
// npm を使う場合
npm install @material-ui/core

// yarn を使う場合
yarn add @material-ui/core
```

### バージョン情報

- react: 16.13.1
- @material-ui/core: 4.9.9

# ファイル構成

<img width="280" alt="スクリーンショット 2020-04-05 18.28.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/376127/04f37343-3429-5a19-5291-5a13ca5c9207.png">

# コンポーネントの実装

## Display

```jsx:Display.js
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Box from '@material-ui/core/Box';
import Typography from '@material-ui/core/Typography';

const useStyles = makeStyles((theme) => ({
    box: {
        width: "100%",
        margin: "10px auto",
    },
  }));

export default function Display(props) {
  const classes = useStyles();

  return (
      <div>
       <Box className={classes.box}>
           <Typography variant="h3">
                {props.children}
           </Typography>
       </Box>
      </div>
  );
}
```

## NumberButton

```jsx:NumberButton.js
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

export default function NumberButton(props) {
  const useStyles = makeStyles((theme) => ({
    margin: {
      margin: theme.spacing(1),
      backgroundColor: "#f48fb1",
    },
  }));
  const classes = useStyles();

  return (
      <div>
        <Button className={classes.margin} onClick={props.onClick}>
            {props.children}
        </Button>
      </div>
  );
}
```

### ポイント

- propsでonClickイベントを受け取って、ButtonのonClickに接続しています

## LongButton

```jsx:LongButton.js
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

export default function LongButton(props) {
  const useStyles = makeStyles((theme) => ({
    margin: {
      margin: theme.spacing(2),
      padding: theme.spacing(1),
      backgroundColor: props.backgroundColor,
    },
  }));
  const classes = useStyles();

  return (
      <div>
        <Button className={classes.margin} onClick={props.onClick}>
            {props.children}
        </Button>
      </div>
  );
}
```

### ポイント

- 数字ボタンと他のボタンでスタイルを変えようと思いましたが、同じような見た目になってしまいました。こちらは背景色をpropsに渡すことができます。

## GuessHistory

```jsx:GuessHistory.js
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import Box from '@material-ui/core/Box';

const useStyles = makeStyles((theme) => ({
    box: {
        textAlign: "left",
        margin:"20px 0",
        padding:"0 20px"
    },
  }));

export default function GuessHistory(props) {
  const classes = useStyles();

  const history = props.history;

  return (
      <div>
        <Typography>予想履歴</Typography>
        <Box className={classes.box}>
          {
            history.map((guess, index) => 
            <Box key={index} marginBottom="10px">
              <Typography variant="h5">
                {index+1}.  {guess[0]}{guess[1]}:  {guess[2]}
              </Typography>
            </Box>
            )
          }
        </Box>
      </div>
  );
}
```

###ポイント

- `history`を`props`で受け渡しています。`history.map`で単一の`guess`を受け取り、`Box`内で表示しています。
- `map`を使うときはコンポーネントに`key`をつけるのを忘れずに。

## 正解モーダル

```jsx:SuccessModal.js
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Modal from '@material-ui/core/Modal';
import Backdrop from '@material-ui/core/Backdrop';
import Fade from '@material-ui/core/Fade';
import Box from '@material-ui/core/Box';
import Typography from '@material-ui/core/Typography';

const useStyles = makeStyles((theme) => ({
  modal: {
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
  },
  paper: {
    backgroundColor: theme.palette.background.paper,
    border: '2px solid #000',
    boxShadow: theme.shadows[5],
    padding: theme.spacing(2, 4, 3),
  },
}));

export default function TransitionsModal(props) {
  const classes = useStyles();
  const guessTimes = props.guessTimes;
  const answer = props.answer;

  const open = props.open
  
  return (
    <div>
      <Modal
        aria-labelledby="transition-modal-title"
        aria-describedby="transition-modal-description"
        className={classes.modal}
        open={open}
        closeAfterTransition
        BackdropComponent={Backdrop}
        BackdropProps={{
          timeout: 500,
        }}
      >
        <Fade in={open}>
          <div className={classes.paper} >
              <Box marginBottom="30px" id="transition-modal-title">
                <Typography  variant="h4">正解です！</Typography>
              </Box>
              <Box  id="transition-modal-description">
                <Typography variant="h5">正解の数字:　{answer}</Typography>
                <Typography variant="h5">正解までの質問数：　{guessTimes}</Typography>
              </Box>
          </div>
        </Fade>
      </Modal>
    </div>
  );
}
```

### ポイント

- 元となるコンポーネントはmaterial-uiの公式ドキュメント、モーダルから引っ張ってきました
- `props`で`open`を受け取ることで、コンテナの`open`stateが`true`になったときに子のopenも更新されます。

# コンテナの実装

```jsx:NumberGuess.js
import React, {useState, useEffect} from 'react';
//components
import NumberButton from '../components/NumberButton';
import LongButton from '../components/LongButton';
import Display from '../components/Display';
import GuessHistory from '../components/GuessHistory';
import SuccessModal from '../components/SuccessModal';
//material-ui
import Box from '@material-ui/core/Box';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';

export default function NumberGuess() {
    //states
    const [inputNumber, setInputNumber] = useState(0);
    const [questionContent, setQuestionContent] = useState("");
    const [history, setHistory] = useState([]);
    const [answer,setAnswer] = useState(0);
    const [open, setOpen] = useState(false);
    const [guessTimes, setGuessTimes] = useState(0);
    
    //ページが読み込まれたときに一度だけ実行させる
    useEffect(() => {
        setAnswer(Math.floor( Math.random() * (100) ) + 1);
    }, [])

    //数字ボタンをクリックしたとき
    const handleNumberClick = (num) => {
        let newInputNumber = inputNumber*10 + num;
        if(newInputNumber >= 1 && newInputNumber <= 100){
            setInputNumber(newInputNumber);
        }
    }

    //予想の内容ボタンをクリックしたとき
    const handlequiestionContentClick = (content) => {
        if(content === "biggerThan"){
            setQuestionContent("より上ですか？");
        } else if(content === "smallerThan"){
            setQuestionContent("より下ですか？");
        } else if(content === "exact"){
            setQuestionContent("ですか？");
        }
    }

    //クリアボタンをクリックしたとき
    const handleClearClick = () => {
        setInputNumber(0);
        setQuestionContent("");
    }

    //予想ボタンをクリックしたとき
    const handleSubmitClick = () => {
        //予想の内容が入力されていない場合はすぐに返す
        if(questionContent === "") return;
        //予想した回数を1増やす
        setGuessTimes(guessTimes + 1);

        if(questionContent === "より上ですか？"){
            if(inputNumber < answer){
                setHistory([
                    ...history,
                    [inputNumber, "より上ですか？", "はい"]
                ]);
            } else {
                setHistory([
                    ...history,
                    [inputNumber, "より上ですか？", "いいえ"]
                ]);
            }
        } else if(questionContent === "より下ですか？"){
            if(inputNumber > answer){
                setHistory([
                    ...history,
                    [inputNumber, "より下ですか？", "はい"]
                ]);
            } else {
                setHistory([
                    ...history,
                    [inputNumber, "より下ですか？", "いいえ"]
                ]);
            }
        } else if(questionContent === "ですか？"){
            if(inputNumber === answer){
                setHistory([
                    ...history,
                    [inputNumber, "ですか？", "はい"]
                ]);
                //正解の場合にモーダルを開く
                setOpen(true);
            } else {
                setHistory([
                    ...history,
                    [inputNumber, "ですか？", "いいえ"]
                ]);
            }
        }
        setInputNumber(0);
        setQuestionContent("");
    }
  return (
      <div>
          <Box marginBottom="20px">
            <Typography variant="h4">React Number Guess</Typography>
            <Typography variant="h6">1~100の数字を予想しよう</Typography>
          </Box>
          <Grid container spacing={1}>
              <Grid item sm={6} xs={12}>
                <Display>{inputNumber} {questionContent}</Display>
                <Box display="flex" flexDirection="column">
                    <Box display="flex" justifyContent="center">
                        <NumberButton onClick={() => handleNumberClick(0)}>0</NumberButton>
                        <NumberButton onClick={() => handleNumberClick(1)}>1</NumberButton>
                        <NumberButton onClick={() => handleNumberClick(2)}>2</NumberButton>
                        <NumberButton onClick={() => handleNumberClick(3)}>3</NumberButton>
                        <NumberButton onClick={() => handleNumberClick(4)}>4</NumberButton>
                    </Box>
                    <Box display="flex" justifyContent="center">
                        <NumberButton onClick={() => handleNumberClick(5)}>5</NumberButton>
                        <NumberButton onClick={() => handleNumberClick(6)}>6</NumberButton>
                        <NumberButton onClick={() => handleNumberClick(7)}>7</NumberButton>
                        <NumberButton onClick={() => handleNumberClick(8)}>8</NumberButton>
                        <NumberButton onClick={() => handleNumberClick(9)}>9</NumberButton>
                    </Box>
                    <Box display="flex" justifyContent="center">
                        <LongButton onClick={() => handlequiestionContentClick("biggerThan")} backgroundColor="#f48fb1">より上ですか？</LongButton>
                        <LongButton onClick={() => handlequiestionContentClick("smallerThan")} backgroundColor="#f48fb1">より下ですか？</LongButton>
                        <LongButton onClick={() => handlequiestionContentClick("exact")} backgroundColor="#f48fb1">ですか？</LongButton>
                    </Box>
                    <Box display="flex" justifyContent="center">
                        <LongButton onClick={handleClearClick}  backgroundColor="#bdbdbd">クリア</LongButton>
                        <LongButton onClick={handleSubmitClick}  backgroundColor="#b2ff59">予想する</LongButton>
                    </Box>
                </Box>
              </Grid>
              <Grid item sm={6} xs={12}>
                <GuessHistory history={history}/>
              </Grid>
          </Grid>
          <SuccessModal open={open} guessTimes={guessTimes} answer={answer}/>
            
      </div>
  );
}
```

### ポイント

- useEffectを使って、マウント時に一度だけanswerを設定するようにしました。単純に`const answer = ...`としていると、ボタンを押すごとに更新されてしまいます。
-  `handleSubmitClick`の`setHistory`内では元の`history`を展開して、最後に新たな要素を追加しています。

## App.js

```jsx:App.js
import React from 'react';
import './App.css';

import NumberGuess from './containers/NumberGuess'

function App() {
  return (
    <div className="App">
      <div className="container">
        <NumberGuess />
      </div>
    </div>
  );
}

export default App;
```

## App.css

```App.css
.App {
  text-align: center;
}

.container{
  margin: 80px auto 80px auto;
  max-width: 900px;
  background-color: #80deea;
}
```

以上で完成です！

# おまけ

[こちらの記事](https://qiita.com/EisKern/items/15dcf7864fa49df8f247)を参考に、gh-pagesでサイトを公開しました。
https://shintaro-hirose.github.io/react-number-guess/

今後もReactで簡単なアプリを作っていきます！