【React + Material-UI】タイピングゲームを作ろう！【2020年4月版】

# はじめに

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

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

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

1. [電卓アプリ](https://qiita.com/shinhiro/items/91b48dfdb47eece38604)
2. [数字当てゲーム](https://qiita.com/shinhiro/items/312d72ab788ca1c45da0)

# 今回作ったもの

[typeee!](https://shintaro-hirose.github.io/typeee/)
英単語のタイピング速度を測定できるアプリです。ランダムな英単語20個を入力したタイムからCPM(1分に入力できる文字数)を計算し、評価します。

是非、使ってツイートしてみてください。とても嬉しくなります。
<img width="300" alt="スクリーンショット 2020-04-09 22 34 27" src="https://user-images.githubusercontent.com/48212107/78900848-6b3abb80-7ab2-11ea-9c13-59c00f504d7c.png"><img width="300" alt="スクリーンショット 2020-04-09 22 35 15" src="https://user-images.githubusercontent.com/48212107/78900858-71309c80-7ab2-11ea-89ae-4e4e811b4351.png">

特徴的な機能としては、

- タイムとCPMをリアルタイム表示している
- 「もう一度遊ぶ」ボタンでページをリロードすることなく初期状態に戻している

が挙げられます。

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

# 戦略

Reactコンポーネントには**Material-UI**を使い、保持するstateをReactの**useState**で実装します。今回はstateの数が多く、子コンポーネントに受け流す記述が多く発生しました。これ以上コンポーネントを増やす場合はReduxを使うかContext APIを使うと思います。

英単語のデータは[クジラWeb API](https://api.aoikujira.com/index.php?eitango2000&show)の頻出英単語2000というところからcsv形式で入手したものを**json形式**に変換して静的に格納しています。アプリを作る段階ではaxiosを使ってAPI叩いてどうのこうのしようと思っていたのですが、今回のアプリでは単語の名前データは使いまわしでOKなのでやめました。

ちなみに、HTTPリクエストでAPIを使ってテストしたい場合は[**Postman**](https://www.postman.com/)というアプリをおすすめします。

### タイピング部分をどうするか

Reactには[**合成イベント**](https://ja.reactjs.org/docs/events.html#form-events)というものが用意されています。コンポーネントに対するユーザーの入力に反応して何かやってくれるものです。ボタンで使う`onClick`やフォームで使う`onChange`もこの一種ですね。キーボードの文字入力に反応する`onKeyPress`というのも用意されていて、今回はこれを使用します。
`onKeyPress`では押されたキーの情報を保持してくれて、`event.hogehoge`で参照できます(hogehogeに使いたいプロパティの名前を入れる)。下のように[React公式](https://ja.reactjs.org/docs/events.html#form-events)にプロパティ一覧が載っているのでみてください。
<img width="300" alt="スクリーンショット 2020-04-10 14 05 52" src="https://user-images.githubusercontent.com/48212107/78964176-6cacc800-7b34-11ea-807c-696fb8c40a85.png">

今回は`key`という、押されたキーの文字列を返してくれるプロパティを使います。

# component, containerの洗い出し

## component

- 説明蘭: 説明文が書かれただけのcomponent。親からpropsを受け取ったりとかもしません。

<img width="608" alt="スクリーンショット 2020-04-10 14 13 37" src="https://user-images.githubusercontent.com/48212107/78964525-7e429f80-7b35-11ea-9724-0240b9769fb3.png">

- 終了時のモーダル: タイピングが終了したと同時に開くモーダル。親から結果の情報などをいろいろ受け取る。

<img width="400" alt="スクリーンショット 2020-04-09 22 35 15" src="https://user-images.githubusercontent.com/48212107/78900858-71309c80-7ab2-11ea-89ae-4e4e811b4351.png">

###留意

<img width="500" alt="スクリーンショット 2020-04-10 14 20 42" src="https://user-images.githubusercontent.com/48212107/78964922-82bb8800-7b36-11ea-84a8-89b7e44ba9fd.png">

本来、このタイピング実行部分はコンポーネントにするべきですが、**今回はContanierにそのまま記述しました**。なぜならば、これをコンポーネントにした場合、コンポーネント間でのstateの受け渡しが多いためにreactのみでは辛いからです。もしコンポーネント化するならばcontext APIかReduxを導入するでしょう。

## container

- Home: 1画面しかないですが、Homeです。ここにタイピング実行エリアの記述をしています。

# stateの洗い出し

1. `typingString`: string, 入力する文字列全体。`useEffect`内で、`refresh`が更新されるたびに英単語を20個ランダムに並べて生成している。
2. `loading`: boolean, `useEffect`で文字列を生成しているときにtrueとなり、生成できたらfalse。ただ、今回はデータをHTTP通信で取得するわけではないから、必須ではない。
3. `currentIndex`: number, 文字列の中でいまどこを入力しているのかを追うためのもの。これがtypingString以上になったときに終了してモーダルを開く。
4. `isMisstype`: boolean, 現在入力している文字が1度でもミスタイプされたときに`true`となり、正しく入力されると`false`になる。文字を赤くするかどうかの判別に使う。
5. `missCount`: number, ミスタイプした回数。
6. `finished`: boolean, タイプが終わったら`true`。
7. `started`: boolean, タイピングが始まったら`true`。
8. `timeOfTyping`: number, タイムにかかっている時間(ms)。
9. `modalOpen`: boolean, `true`の場合モーダルを開く。
10. `refresh`: number, ランダムな数字が入る。モーダルの「もう一度」ボタンを押して次の英単語を取得するときに使用する。`useEffect`の第2引数に指定しているため、これが更新されると`useEffect`内部処理が走る。

# インストール

## create react app

```
npx create-react-app react-typeee
cd react-typeee
```

## @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="260" alt="スクリーンショット 2020-04-10 14 53 56" src="https://user-images.githubusercontent.com/48212107/78966538-1f802480-7b3b-11ea-98e8-212e63d88c28.png">

# コンポーネントの実装

## Discription

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

const useStyles = makeStyles((theme) => ({
    root:{
        width:"80%",
        margin:"30px",
    },
    content:{
        fontSize: "16px",
    },
}));

function Discription() {
    const classes= useStyles();
    return (
        <div className={classes.root}>
            <Typography className={classes.content}>
                <b>あなたの英単語タイピング速度はどのくらいでしょう？</b><br />
                CPM(Characters Per Minute)：1分間あたりに入力できる文字数から判定します。<br />
                単語の間はスペースを入力してください。<br />
                下の英文のどこかをクリックしたあとにスタートできます。
            </Typography>
        </div>
    )
}

export default Discription;
```

## SuccessModal

```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 Button from '@material-ui/core/Button';

import logo from '.././images/typeee-logo.svg';
import {timeFormatting, cpmToRank, cpmToDiscription} from '../util/util';
import Typography from '@material-ui/core/Typography';

import {TwitterShareButton, TwitterIcon} from 'react-share';

const useStyles = makeStyles((theme) => ({
  modal: {
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
  },
  paper: {
    backgroundColor: theme.palette.background.paper,
    textAlign:'center',
    border: '2px solid #000',
    boxShadow: theme.shadows[5],
    padding: theme.spacing(2, 4, 3),
  },
  logo:{
    width: "150px",
    marginBottom: "20px"
},
  content:{
      fontSize:"20px",
      fontFamily: "monospace",
  }
}));

function SuccessModal(props) {
  const classes = useStyles();

  const result = props.result;
  const cpm = (result.charLength/ result.timeOfTyping * 1000 * 60).toFixed(0);

  return (
    <div>
      <Modal
        aria-labelledby="transition-modal-title"
        aria-describedby="transition-modal-description"
        className={classes.modal}
        open={props.modalOpen}
        onClose={props.modalClose}
        closeAfterTransition
        BackdropComponent={Backdrop}
        BackdropProps={{
          timeout: 500,
        }}
      >
        <Fade in={props.modalOpen}>
          <div className={classes.paper}>
                <img src={logo} alt="logo" className={classes.logo} />
            <div id="transition-modal-description">
                <Typography className={classes.content}>
                    文字数: {result.charLength}<br />
                    タイム: {timeFormatting(result.timeOfTyping)}<br />
                    精度: {(result.charLength/(result.charLength+result.missCount) * 100).toFixed(1)}%<br />
                    CPM(1分間あたりの入力文字数): {cpm}<br />
                </Typography>
                <Box marginTop="20px">
                    <Typography style={{display: "inline", fontSize:"20px"}}>あなたは・・・</Typography>
                    <Typography style={{display: "inline", fontSize:"28px", fontWeight:"bold"}}>{cpmToRank(cpm)}</Typography>
                </Box>
                <Box marginTop="20px" maxWidth="500px" >
                    <Typography style={{fontSize:"18px"}}> {cpmToDiscription(cpm)}</Typography>
                </Box>
                <Box display="flex" justifyContent="center" m={4}>
                    <Button onClick={props.refreshAll} color="primary" variant="contained">もう一度</Button>
                    <Box marginLeft="50px">
                      <TwitterShareButton 
                      url="https://shintaro-hirose.github.io/typeee/" 
                      title={`typeee!でタイピング速度を計測しました！ ${result.charLength}文字, ${timeFormatting(result.timeOfTyping)}秒, 精度 ${(result.charLength/(result.charLength+result.missCount) * 100).toFixed(1)} %, ${cpm} CPM, 評価は "${cpmToRank(cpm)}" でした。`} 
                      hashtags={["typeee"]}>
                        <TwitterIcon size={40} round={true}/>
                      </TwitterShareButton>
                    </Box>
                   
                </Box>
                
            </div>
            
          </div>
        </Fade>
      </Modal>
    </div>
  );
}

export default SuccessModal;
```

### ポイント

- 元となるコンポーネントはMaterial-UIの公式ドキュメントのModalからコピーしました。
- 親からpropsで`result`, `modalOpen`, `refreshAll`というプロパティを受け取っています。
- 「もう一度」ボタンの`onClick`で親の`refreshAll`が呼び出され、親の`refreshAll`ではすべてのstateが初期化されるのです。

# コンテナの実装

```jsx:Home.js
import React,{useState, useEffect, useRef} from 'react'

import {makeStyles} from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import Box from '@material-ui/core/Box';

import logo from '.././images/typeee-logo.svg';

import SuccessModal from '../components/SuccessModal';
import Discription from '../components/Discription';
import {timeFormatting} from '../util/util'

const {dictionary} = require('../util/dictionary');

const useStyles = makeStyles(() => ({
    logo:{
        width: "400px",
    },
    inputBox:{
        padding: "30px",
        marginBottom: "30px",
    },
    greenFont:{
        color:"#689f38", 
        display:"inline",
        fontFamily:"Times New Roman",
        fontSize: "50px"

    },
    redFont:{
        backgroundColor:"#e0e0e0", 
        color:"red",
        display:"inline",
        fontFamily:"Times New Roman",
        fontSize: "50px"

    },
    greyFont:{
        color:"grey", 
        display:"inline",
        fontFamily:"Times New Roman",
        fontSize: "50px"
    },
    blackFont:{
        backgroundColor:"#e0e0e0", 
        display:"inline",
        fontFamily:"Times New Roman",
        fontSize: "50px"

    },
    stats:{
        display: "inline",
        fontSize: "20px",
        margin: "0 30px",
    },
    rights:{
        fontSize: "20px",
        padding:"20px",
    }
}));

function Home() {
    const classes = useStyles();
    const [typingString, setTypingString] = useState("");
    const [loading, setLoading] = useState(false);
    const [currentIndex, setCurrentIndex] = useState(0);
    const [isMisstype, setIsMisstype] = useState(false);
    const [missCount, setMissCount] = useState(0);
    const [finished, setFinished] = useState(false);
    const [started, setStarted] = useState(false);
    const [timeOfTyping, setTimeOfTyping] = useState(0);
    const [modalOpen, setModalOpen] = useState(false);
    const [refresh, setRefresh] = useState("");

    let timer = useRef(null);


    useEffect(() => {
        setLoading(true)
        let ts = '';
        for (var i=0; i<20; i++){
            let word = dictionary[Math.floor( Math.random() * (2000) )].letter;
            ts += word + ' '
        }
        let newTypingString = ts.slice(0,-1)
        setTypingString(newTypingString);
        setLoading(false);
    }, [refresh])

    const handleKeyPress = (e) => {
        if(finished) return;
        if(!started){
            setStarted(true);
            const startTime = new Date().getTime();
            timer.current = setInterval(() => {
                setTimeOfTyping((new Date().getTime()) - startTime)
            }, 10)
        }
        if(e.key === typingString[currentIndex]){
            setIsMisstype(false);
            setCurrentIndex(currentIndex + 1);
            if(currentIndex+1 >= typingString.length){
                clearInterval(timer.current);
                setFinished(true);
                setModalOpen(true);
            }
        } else {
            setIsMisstype(true);
            setMissCount(missCount + 1)
        }
    }

    const refreshAll = () => {
        setLoading(false);
        setCurrentIndex(0);
        setIsMisstype(false);
        setMissCount(0);
        setFinished(false);
        setStarted(false);
        setTimeOfTyping(0);
        setModalOpen(false);
        setRefresh(Math.random())
    }


    return (
        loading ? (
            <p></p>
        ) : (
            <div align="center" >
                <img src={logo} alt="logo" className={classes.logo} />
                <Discription />
                <div onKeyPress={(e) => handleKeyPress(e)} tabIndex={0} className={classes.inputBox}>
                    <Typography className={classes.greenFont}>
                        {typingString.slice(0,currentIndex)}
                    </Typography>
                    {isMisstype ? (
                        <Typography className={classes.redFont}>
                            {typingString[currentIndex]}
                        </Typography>
                    ) : (
                        <Typography className={classes.blackFont}>
                            {typingString[currentIndex]}
                        </Typography>
                    )}
                    
                    <Typography className={classes.greyFont}>
                        {typingString.slice(currentIndex+1,typingString.length)}
                    </Typography>
                </div>
                <Box display="flex" justifyContent="center">
                        <Typography className={classes.stats}>
                            ミスタイプ: {missCount}回
                        </Typography>
                        <Typography className={classes.stats}>
                            タイム: {timeFormatting(timeOfTyping)}
                        </Typography>
                </Box>
                <Box marginBottom="50px">
                    <Typography className={classes.stats}>
                        CPM:  {(currentIndex === 0) ? "-" : (currentIndex / timeOfTyping * 1000 * 60).toFixed(0)}
                    </Typography>
                </Box>
                <SuccessModal
                result={{
                    timeOfTyping,
                    missCount,
                    charLength: typingString.length
                }}
                modalOpen={modalOpen}
                modalClose={() => setModalOpen(false)} 
                refreshAll={refreshAll}
                />
            </div>
        )
       
    )
}

export default Home;
```

### useEffectについて

- `refresh`という値が更新されるたびに処理を行うようにしています。
-  `dictionary`という、英単語の情報が2000個入った配列からランダムで20個の英単語を取り出し、文字列として`typingString`にセットしています。

### タイピング部分の表示について

- 現在タイピング中の文字、すでにタイピングし終わった文字、まだタイピングしていない文字の3種類にわけて、それらを結合しています。

### handleKeyPressについて

- `e.key`で入力されたキーの文字列を取得しています
- 正しい場合、`currentIndex`を進める、誤りだった場合、`missCount`を上げて`isMisstype`を`true`にします。

### タイムのリアルタイム表示について

- 初めてキーが押されたときに`setInterval`が実行され、10msごとに現在時間からスタート時間を差し引いたものを返します。(10msごとに10あげるカウントを用意するやり方だとタイムがずれる場合があるので避けます。)
- finish時に`clearInterval`でintervalを止めます。
- **関数コンポーネントで`setInterval`と`clearInterval`を使う場合、注意が必要です。**はじめに`let timer = useRef(null);`とすることで、共通のtimerを指定することができます。
- useRefに関する警告メッセージが発生しているので、この使い方はよくないかもしれません。これについては調べてみます。

### 「もう一度」ボタンを押したときの`refreshAll`について

もう一度遊ぶ場合、同じページをロードさせてstateや文字列を初期化することはできます。しかし、それはDOMを再度1からレンダリングすることになるのでReactの利点を無視した実装となります。
そのような実装にせず、コンテナ内でstateを初期化し、useEffectで新しい文字列を取得するようにしました。


## App.js

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

function App() {

  return (
    <div className="App" >
      <div className="container">
        <Home />
      </div>
      <footer>© 2020 Shintaro Hirose</footer>
    </div>
  );
}

export default App;

```

## App.css

```App.css
.App {
  text-align: center;
  background-color: #eeeeee;
  
}
.container {
  max-width: 800px;
  margin: 0 auto;
  padding-top: 20px;
}

footer {
  background-color: #bdbdbd;
  font-size: 25px;
  padding: 20px;
}
```

以上で完成です！
**utilで関数を定義しているので、それはgithubの[ソースコード](https://github.com/shintaro-hirose/typeee)を確認してください**

# おまけ1 Twitterのシェアアイコンを追加

結果をTwitterで共有しやすくするためにTwitterのシェアボタンをモーダルに追加しました。
react-shareをインストールしてアイコンを設置しました。[公式ドキュメント](https://github.com/nygardk/react-share)に従えば迷うことはないと思います。

# おまけ2 Github pagesで公開する

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

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