23
13

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-04-10

はじめに

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

material-uiの公式ドキュメントも要チェック

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

  1. 電卓アプリ
  2. 数字当てゲーム

今回作ったもの

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

是非、使ってツイートしてみてください。とても嬉しくなります。
スクリーンショット 2020-04-09 22 34 27スクリーンショット 2020-04-09 22 35 15

特徴的な機能としては、

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

が挙げられます。

Githubのソースコード

戦略

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

英単語のデータはクジラWeb APIの頻出英単語2000というところからcsv形式で入手したものをjson形式に変換して静的に格納しています。アプリを作る段階ではaxiosを使ってAPI叩いてどうのこうのしようと思っていたのですが、今回のアプリでは単語の名前データは使いまわしでOKなのでやめました。

ちなみに、HTTPリクエストでAPIを使ってテストしたい場合はPostmanというアプリをおすすめします。

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

Reactには合成イベントというものが用意されています。コンポーネントに対するユーザーの入力に反応して何かやってくれるものです。ボタンで使うonClickやフォームで使うonChangeもこの一種ですね。キーボードの文字入力に反応するonKeyPressというのも用意されていて、今回はこれを使用します。
onKeyPressでは押されたキーの情報を保持してくれて、event.hogehogeで参照できます(hogehogeに使いたいプロパティの名前を入れる)。下のようにReact公式にプロパティ一覧が載っているのでみてください。
スクリーンショット 2020-04-10 14 05 52

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

component, containerの洗い出し

component

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

スクリーンショット 2020-04-10 14 13 37

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

スクリーンショット 2020-04-09 22 35 15

留意

スクリーンショット 2020-04-10 14 20 42

本来、このタイピング実行部分はコンポーネントにするべきですが、今回は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

ファイル構成

スクリーンショット 2020-04-10 14 53 56

コンポーネントの実装

Discription

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

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が初期化されるのです。

コンテナの実装

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を上げてisMisstypetrueにします。

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

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

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

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

App.js

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のソースコードを確認してください

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

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

おまけ2 Github pagesで公開する

こちらの記事を参考に、gh-pagesでサイトを公開しました。
https://shintaro-hirose.github.io/typeee/

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

23
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
13