ツクログ

tsukulognet

tsukulognet

道産子。Reactでなまら面白いものを作りたい。

Reactでモグラ叩きを作る

eye catch

Reactでモグラ叩きを作る方法です。

Demo

初期画面のスタートボタンを押すとゲームがスタートし、モグラを叩くとスコアが1ずつ増えます。制限時間を過ぎるとゲーム終了です。

開発環境を構築する

PCにnode.jsをインストール後、git bashなどで下記のコマンドを実行して、Reactアプリの開発環境を構築します。

npx create-react-app whack-a-mole
cd whack-a-mole
npm start

上記のwhack-a-moleがプロジェクトのフォルダ名になります。

ディレクトリ構成

src以下を下記の通りにします。現時点でファイルの中身は空です。

whack-a-mole
 └── src
     ├── components
     │   ├── Button.js
     |   ├── Dialog.js
     |   ├── Display.js
     |   ├── Mole.js
     |   ├── TextEmergeEffect.js
     │   └── WhackAMole.js
     ├── hooks
     │   ├── useCountDownTimer.js
     │   ├── useElementScale.js
     |   ├── useMoleAnimation.js
     │   └── useMoles.js
     ├── App.css
     ├── App.js
     ├── constants.js
     ├── index.css
     ├── index.js
     └── utils.js

ページ全体のスタイルを定義する

ページ全体に適用するスタイルを定義します。 index.css

@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

* {
  font-family: 'Press Start 2P', cursive;
}

body {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  margin: 0; 
}

:root {
  --brand-color: rebeccapurple;
  --accent-color: rgb(62, 93, 228);
  --text-color: rgb(40, 40, 40);
  --bg-color: rgb(240, 240, 243);
}

Appコンポーネントのスタイルを定義する

ルートとなるAppコンポーネントのスタイルを定義します。 App.css

.App {
  background-color: #444;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  overflow: hidden;
  width: 100%;
}

主にAppコンポーネント直下のコンポーネントを画面中央に配置するスタイルと、スクロールバーを隠すスタイルを記述しています。

定数を定義する

アプリケーション内で使用する値を定数として定義します。通常、定数は大文字とアンダーバーで定義されます。 constants.js

export const MOLE_LENGTH = 9
export const MOLE_HEIGHT = 130
export const MOLE_SPEED = .3

export const APPEARANCE_INTERVAL = 1000

export const MOLE = {
    default: {
        eyes: ['●', '●'],
        complexion: 'sienna',
        height: MOLE_HEIGHT
    },
    hurt: {
        eyes: ['>', '<'],
        complexion: '#f7504a',
        height: MOLE_HEIGHT
    }   
}

export const TIME_LIMIT = {
    min: 1,
    sec: 0
}

MOLEはモグラの基本情報からなるオブジェクトです。defaultは通常のモグラの状態を示し、hurtは叩かれたときのモグラの状態を示します。

ユーティリティ関数を作成する

今回作成するユーティリティ関数は、要素とその個数を引数で受け取って配列を返すcreateArray関数です。 utils.js

export const createArray = (length, elem) => {
    return new Array(length).fill(elem)
}

アニメーションライブラリのパッケージを導入する

モグラが顔を出して引っ込む動作と、叩かれたときに沈む動作をアニメーションで表現するため、アニメーションライブラリ「GSAP」のパッケージgsapとGSAPをReactで使えるようにするパッケージreact-gsap-enhancerを下記のコマンドでインストールします。

npm install gsap react-gsap-enhancer

当初はCSS3のキーフレームアニメーションで動きを表現しようと試みましたが、アニメーションの途中で別のアニメーションに切り替えることができなかったため断念しました。

独自フックを作成する

コンポーネントのロジックをまとめた独自フックをコンポーネント毎に作成します。

useCountDownTimer

カウントダウンタイマーに関する独自フックuseCountDownTimerを作成します。 useCountDownTimer.js

import { useState, useCallback, useRef } from 'react'

export const useCountDownTimer = () => {
    const [timeLimit, setTimeLimit] = useState({ min: 0, sec: 0 })
    const intervalID = useRef(null)

    const formatTime = useCallback(num => {
        let result = num;

        if(num < 10) {
            result = `0${num}`
        }

        return result
    }, [])

    const initializeTime = useCallback(timeLimit => {
        const newTimeLimit = timeLimit

        if(timeLimit.min >= 60) {
            newTimeLimit.min = 0
        }

        if(timeLimit.sec >= 60) {
            newTimeLimit.sec = 0
        }

        setTimeLimit(newTimeLimit)
    }, [])

    const stopTime = useCallback(() => {
        clearInterval(intervalID.current)
    }, [intervalID.current])

    const startTime = useCallback(() => {
        const tick = () => {
            setTimeLimit(prevTimeLimit => {
                const newTimeLimit = Object.assign({}, prevTimeLimit)

                if(newTimeLimit.min > 0 && newTimeLimit.sec <= 0) {
                    newTimeLimit.min = newTimeLimit.min - 1
                }

                if(newTimeLimit.min >= 0 && newTimeLimit.sec <= 0) {
                    newTimeLimit.sec = 59
                }else{
                    newTimeLimit.sec = (newTimeLimit.sec % 60) - 1
                }

                if(newTimeLimit.min <= 0 && newTimeLimit.sec <= 0) {
                    stopTime()
                }

                return newTimeLimit
            })
        }

        intervalID.current = setInterval(tick, 1000)
    }, [])

    return [timeLimit, initializeTime, startTime, formatTime]
}

この独自フックは現在のタイムリミットとそれを操作する関数を返します。

タイムリミットTimeLimitは分minと秒secを持つオブジェクト形式のステートです。

formatTime関数は、タイマーの分または秒が10未満になったときに頭に0を付けて常に2ケタで表示させるための関数です。

その他、initializeTimeはタイムを初期化する関数であり、startTimeとstopTimeはそれぞれタイマーをスタートストップする関数です。

useMoleAnimation

モグラのアニメーションに関する独自フックuseMoleAnimationを作成します。 useMoleAnimation.js

import { useCallback, useRef } from 'react'
import { MOLE_HEIGHT } from '../constants'
import GSAP from 'react-gsap-enhancer'
import { TweenMax } from 'gsap'

export const useMoleAnimation = () => {
    const moleElementRef = useRef(null)
    
    const sinkMole = useCallback(() => {
        TweenMax.to(moleElementRef.current, .1, {y: 0})
    })

    const moveMole = useCallback(() => {
        const tween = TweenMax.to(moleElementRef.current, .5, {y: -`${MOLE_HEIGHT}`})
        tween.repeat(1)
        tween.yoyo(true)
    })

    return [moleElementRef, sinkMole, moveMole]
}

ここで先ほどインストールしたパッケージreact-gsap-enhancerからGSAPを、gsapからTweenMaxを読み込みます。

moleElementRefは、後にmoleElementRefを.moleのref属性に指定して.moleのレンダリング後にmoleElementRef.currentでコンポーネント内で.moleを参照するために使います。

sinkMoleはモグラが沈むアニメーションを表現し、moveMoleはモグラが上下に動くアニメーションを表現する関数です。

つまり、moleElementRefを.moleのref属性に設定して、これら2つの関数を呼び出すと.moleにアニメーションが適用されます。

useElementScale

ウィンドウの高さや幅の変化に応じてアプリのサイズを常にウィンドウ全体に収めつつ縦横比を維持したまま変化させたいので、ウィンドウの比率に応じた要素の拡大率を取得する独自フックuseElementScaleを作成します。

useElementScale.js

import { useState, useRef, useCallback, useLayoutEffect } from 'react'

export const useElementScale = () => {
    const [scale, setScale] = useState(1)
    const elementRef = useRef(null)

    const updateScale = useCallback(() => {
        const screenWidth = window.innerWidth
        const screenHeight = window.innerHeight

        const element = elementRef.current
        
        const width = element.clientWidth
        const height = element.clientHeight

        const ratio = height / width    
        const screenRatio = screenHeight / screenWidth
        
        if(screenRatio > ratio) {
            setScale(screenWidth / width)
        }else {
            setScale(screenHeight / height)
        }
    }, [])

    useLayoutEffect(() => {
        updateScale()
        window.addEventListener('resize', () => updateScale())
    })

    return [scale, elementRef]
}

この独自フックは後に拡大する要素のrefとなるelementRefと要素の拡大率scaleを返します。

この独自フックが呼ばれると、useLayoutEffectによって要素のレンダリング後にupdateScale関数が実行されてウィンドウの比率に応じた要素の拡大率が更新されます。useEffectではなくuseLayoutEffectを使うことで、ページ読み込み後に拡大前の要素が一瞬だけ表示されるのを防ぎます。

また、addEventListenerでwindowのresizeイベントに対してupdateScale関数を登録することで、ウィンドウサイズが変わる度に拡大率が再計算されるようにしています。

updateScale関数は、拡大の対象要素の縦横比ratioとウィンドウの縦横比screenRatioを比較し、ウィンドウの縦横比が要素の縦横比よりも大きい、つまりウィンドウが要素よりも幅に対する高さの割合が大きければ、要素をscreenWidth / widthの倍率(screenWidth / widthスケール)で拡大します。これにより要素はウィンドウの幅にぴったり収まるように拡大されます。

反対に、ウィンドウが要素よりも幅に対する高さの割合が小さければscreenHeight / heightの倍率で要素を拡大します。これにより要素はウィンドウの高さにぴったり収まるように拡大されます。

useMoles

モグラに関する独自フックuseMolesを作成します。 useMoles.js

import { useState, useCallback } from 'react'
import { MOLE, MOLE_LENGTH } from '../constants'

export const useMoles = stage => {
    const [moles, setMoles] = useState(createArray(MOLE_LENGTH, MOLE.default))

    const moveMole = useCallback(num => {
        setMoles(prevMoles => prevMoles.map(prevMole => (
            prevMole.num === num ? {
                ...prevMole,
                isMove: true
            } : prevMole
        )))
    }, [])

    const initializeMoles = useCallback(() => {
        setMoles(prevMoles => prevMoles.map((prevMole, i) => ({
            ...MOLE.default,
            isMove: false,
            isStruck: false,
            num: i + 1
        })))
    }, [])

    const hitMole = useCallback(num => {
        setMoles(prevMoles => prevMoles.map(prevMole => (
            prevMole.num === num ? {
                ...prevMole,
                ...MOLE.hurt,
                isStruck: true
            } : prevMole
        )))
    }, [])
  
    return [moles, moveMole, initializeMoles, hitMole]
}

この独自フックはモグラの基本情報が9つ格納されたmoles配列のステートと、それを操作する3つの関数を返します。

moveMole関数はゲーム中に一定の間隔で呼ばれる関数であり、受け取った引数numと同じnumを持つモグラデータを、isMoveをtrueに切り替えた新しいモグラデータに上書きする処理を行います。つまり、isMoveがtrueになったモグラが動くということなんだよね。

hitMole関数はモグラをクリックで叩いたときに呼ばれる関数であり、受け取った引数numと同じnumを持つモグラデータを、叩かれた状態のモグラを示すオブジェクトMOLE.hurtで上書き&isStruckを trueに切り替えた新しいモグラデータに上書きする処理を行います。つまり、isStruckがtrueになったモグラが沈むということなんだよね。

initializeMoles関数はページ読み込み後とゲーム終了後に呼ばれる関数であり、moles内のモグラデータをデフォルト状態に上書きします。

styled-componentsのインストール

コンポーネントのスタイルをJSファイル内で記述可能にするために、CSS in JSのライブラリ「styled-components」をインストールします。

npm install styled-components

コンポーネントを作成する

モグラ叩きを構成するコンポーネントを作成します。

Button

ボタンに関するButtonコンポーネントを作成します。 Button.js

import React from 'react'
import styled from 'styled-components'

const ButtonComponent = ({className, ...props}) => (
    <button className={className} {...props}></button>
)

const StyledButtonComponent = styled(ButtonComponent)`
    background-color: var(--brand-color);
    border: none;
    border-radius: 3px;
    color: white;
    font-size: 1em;
    padding: 1em 1.8em;
    cursor: pointer;
`

export const Button = props => (
    <StyledButtonComponent {...props} />
)

styled-componentsでスタイルを指定する場合は、コンポーネントの書き方が通常とは異なり、コンポーネントを作成する過程で別の2つのコンポーネントを作成します。(※styled-componentsの書き方は他にもあります。)

初めに、ButtonComponentを作成します。ButtonComponentは次に作成するStyledButtonComponentからクラス名とその他のプロパティを受け取ります。(※今回のstyled-componentsの書き方ではクラス名を受け取るようにしないとスタイルが適用されません。)

そして、StyledButtonComponentを作成します。ここでstyled-componentsによるコンポーネントのスタイル指定を行います。その方法はButtonComponentをstyledで囲み、続く内にスタイルを書きます。

最後に、実際に使用されるButtonコンポーネントを作成します。このコンポーネントに先ほどのStyledButtonComponentを持たせることでButtonコンポーネントに渡されたプロパティがStyledButtonComponent→ButtonComponentへと渡されていきます。

このコンポーネントは次に登場するDialogコンポーネント内で使用されます。

Dialog

ページ読み込み後と終了時に表示するダイアログに関するDialogコンポーネントを作成します。 Dialog.js

import React from 'react'
import styled from 'styled-components'
import { Button } from './Button'

const DialogComponent = ({ className, title, text, buttonName, callback }) => (
    <div className={className}>
        <div className="dialog-content">
            <div className="dialog-content-inner">
                <h1 className="dialog-title">{title}</h1>
                <p className="dialog-text">{text}</p>
                <Button
                    className="dialog-button"
                    onClick={callback}
                >
                    {buttonName}
                </Button>
            </div>
        </div>
        <div className="dialog-overlay"></div>
    </div>
)

const StyledDialogComponent = styled(DialogComponent)`
    & .dialog-content {
        background-color: white;
        box-sizing: border-box;
        border-radius: 2em;
        color: #333;
        display: flex;
        align-items: center;
        justify-content: center;
        height: 300px;
        text-align: center;
        padding: 2em;
        position: absolute;
        top: calc(50% - 150px);
        left: calc(50% - 200px);
        width: 400px;
        z-index: 2;
    }
    
    & .dialog-title {
        font-size: 1.6em;
        margin: 0;
    }

    & .dialog-text {
        color: #777;
        font-size: .4em;
        margin: 4em 0 0;
    }
  
    & .dialog-button {
        font-size: .6em;
        margin:2em 0 0;
    }

    & .dialog-overlay {
        background-color: rgba(0, 0, 0, 0.6);
        position: fixed;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        z-index: 1;
    }
`

export const Dialog = props => (
    <StyledDialogComponent {...props} />
)

このコンポーネントはクラス名、タイトル、テキスト、ボタン名、そしてボタンをクリックしたときに実行されるコールバック関数を受け取ります。

Display

現在のタイムやスコアを等のステータス表示するためのDisplayコンポーネントを作成します。 Display.js

import React from 'react'
import styled from 'styled-components'

const DisplayComponent = ({ className, title, text }) => (
    <div className={className}>{`${title}: ${text}`}</div>
)

const StyledDisplayComponent = styled(DisplayComponent)`
    color: #333;
    font-size: 1em;
`

export const Display = props => (
    <StyledDisplayComponent {...props} />
)

このコンポーネントはクラス名、タイトル、テキストを受け取り、レンダリングされると画面上に「タイトル:テキスト」の形式で表示されます。

Mole

モグラに関するMoleコンポーネントを作成します。 Mole.js

import React, { useEffect } from 'react'
import styled from 'styled-components'

import { useMoleAnimation } from '../hooks/useMoleAnimation'

export const MoleComponent = ({ className, mole, ...props }) => {
    const [moleElementRef, sinkMole, moveMole] = useMoleAnimation()

    useEffect(() => {
        if(mole.isMove) {
            moveMole()
        }

        if(mole.isStruck) {
            sinkMole()
        }
    }, [mole.isMove, mole.isStruck])

    return (
        <div 
            className={`${className} ${className}-${mole.num}`}
            ref={moleElementRef}
            {...props}
        >
            <div className="mole-eyes">
                {mole.eyes.map((eye, index) => (
                    <div className="mole-eye" key={index}>{eye}</div>
                ))}
            </div>
            <div className="mole-mouth">
                <div className="mole-nose"></div>
                <div className="mole-whiskers mole-whiskers-left">
                    <span className="mole-whiskers-whisker"></span>
                </div>
                <div className="mole-whiskers mole-whiskers-right">
                    <span className="mole-whiskers-whisker"></span>
                </div>
            </div>
        </div>
    )
}

const StyledMole = styled(MoleComponent)`
    background-color: ${({mole}) => mole.complexion};
    border-radius: 50% 50% 20% 20%;
    cursor: pointer;
    height: ${({mole}) => mole.height}px;
    position: absolute;
    bottom: -${({mole}) => mole.height}px;
    width: 65%;

    &::after {
        border-radius: 50% 50% 20% 20%;
        content: '';
        display: block;
        height: 100%;
        opacity: 0;
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
    }

    & .mole-eyes {
        position: absolute;
        top: 30px;
        left: 0;
        right: 0;
    }

    & .mole-eye {
        border-radius: 50%;
        color: #333;
        font-size: 1em;
        line-height: 0;
        position:absolute;
        top: 0;

        &:nth-child(1) {
          left: 30px;
        }

        &:nth-child(2) {
          right: 30px;
        }
    }

    & .mole-mouth {
        background-color: tan;
        border-radius: 50%;
        display: block;
        height: 40px;
        position: absolute;
        top: 40px;
        left: calc(50% - (45px / 2));
        width: 45px;
    }

    & .mole-nose {
        background-color: #333;
        border-radius: 6px;
        position: absolute;
        top: calc(50% - 5px);
        left: calc(50% - 5px);
        height: 10px;
        width: 10px;
    }

    & .mole-whiskers {
        position: absolute;
        top: 0;
        bottom: 0;
        width: 100%;

        &-left {
            left: -80%;

            &::before {
                transform: rotate(15deg);
            }

            &::after {
                transform: rotate(-15deg);
            }
        }

        &-right {
            right: -80%;

            &::before {
                transform: rotate(-15deg);
            }
            
            &::after {
                transform: rotate(15deg);
            }
        }

        &-whisker,
        &::before,
        &::after {
            background-color: #fff;
            display: block;
            position: absolute;
            height: 1px;
            left: 0;
            width: 100%;
        }

        &::before,
        &::after {
            content: '';
        }

        &-whisker {
            top: calc(50% - .5px);
        }

        &::before {
            top: 6px;
        }

        &::after {
            bottom: 6px;
        }
    }
`

export const Mole = props => (
    <StyledMole {...props} />
)

このコンポーネントはクラス名、MOLEオブジェクト、イベントハンドラーを受け取ります。

mole.eyes内の要素を各.mole-eyeに当てはめてモグラの目が表示されるようにします。 続けて、styled内のbackground-colorにcomplexionを当てはめてモグラの顔色を設定します。そして、heightにmole.heightを当てはめてモグラの高さを設定し、bottomにもmole.heightを当てはめてモグラを引っ込めた状態にします。

moleElementRefを.moleのref属性に設定し、useEffectフック内でMoleコンポーネントが再レンダリングされる度にisMoveがtrueであればmoveMole関数を呼び出してモグラを上下に動かします。また、isStruckがtrueであればsinkMole関数を呼び出してモグラを沈ませます。

注意点として、useEffectフックを呼び出す際に第二引数は[mole.isMove, mole.isStruck]としてisMole又はisStruckに変化がある度にuseEffectが呼ばれるようにしなければいけません。[]では最初のレンダリング時に一度しか動きませんし、第二引数を省略すれば無限ループに陥ります。

TextEmergeEffect

モグラを叩いたときに浮かび上がるテキストに関するTextEmergeEffectコンポーネントを作成します。 TextEmergeEffect.js

import React from 'react'
import styled, { keyframes, css } from 'styled-components'

const TextEmergeEffectComponent = ({ className, text }) => (
    <p className={className}>{text}</p>
)

const emerge = keyframes`
    0% {
        transform: scale(0.2);
        opacity: 1;
        visibility: visible;
    }
    70% {
        transform: scale(1);
    }
    100% {
        opacity: 0;
        visibility: hidden;
    }
`

const StyledTextEmergeEffectComponent = styled(TextEmergeEffectComponent)`
    animation: ${css`${emerge} 1s 1`};
    color: white;
    font-size: 1em;
    opacity: 0;
    visibility: hidden;
    text-align:center;
`

export const TextEmergeEffect = props => (
    <StyledTextEmergeEffectComponent {...props} />
)

このコンポーネントでは、受け取ったテキストにキーフレームアニメーションで設定した浮かび上がるアニメーションを適用します。

styled内のanimationプロパティにキーフレームアニメーションを指定するにはstyledに加えて、keyframesとcssをファイルの先頭でインポートする必要があります。

WhackAMole

モグラ叩き全体に関するWhackAMoleコンポーネントを作成します。 WhackAMole.js

import React, { useState, useEffect, useCallback, useRef } from 'react'
import styled from 'styled-components'

import { Dialog } from './Dialog'
import { Display } from './Display'
import { Mole } from './Mole'
import { TextEmergeEffect } from './TextEmergeEffect'

import { useMoles } from '../hooks/useMoles'
import { useCountDownTimer } from '../hooks/useCountDownTimer'
import { useElementScale } from '../hooks/useElementScale'

import { MOLE, MOLE_LENGTH, APPEARANCE_INTERVAL, TIME_LIMIT } from '../constants'

const WhackAMoleComponent = ({ className }) => {
    const [isStart, setIsStart] = useState(false)
    const [isStop, setIsStop] = useState(false)
    const [hitCount, setHitCount] = useState(0)

    const [scale, elementRef] = useElementScale()
    const [moles, moveMole, initializeMoles, hitMole] = useMoles()
    const [timeLimit, initializeTime, startTime, formatTime] = useCountDownTimer()
    
    const intervalID = useRef(null)

    const moveMoles = useCallback(() => {   
        intervalID.current = setInterval(() => {
            initializeMoles()

            const randomMoleNum = Math.floor(Math.random() * MOLE_LENGTH) + 1
            moveMole(randomMoleNum)
        }, APPEARANCE_INTERVAL)
    }, [])

    const stopMoles = useCallback(() => {
        clearInterval(intervalID.current)
    }, [])

    const startGame = useCallback(() => {
        setIsStart(true)
        startTime()
        moveMoles()
    }, [])

    const stopGame = useCallback(() => {
        setIsStop(true)
        stopMoles()
    }, [])

    const initializeGame = useCallback(() => {
        setIsStart(false)
        setIsStop(false)
        setHitCount(0)
        initializeMoles()
        initializeTime(TIME_LIMIT)      
    }, [])

    const handleHit = useCallback(e => {
        const moleElement = e.target

        const num = parseFloat(moleElement.className.replace(/[^0-9]/g, ''))
        
        hitMole(num)
        setHitCount(prevHitCount => prevHitCount + 1)
    }, [])

    useEffect(() => {
        initializeGame()
    }, [])

    useEffect(() => {
        if(isStart && timeLimit.min <= 0 && timeLimit.sec <= 0) {
            stopGame()
        }

    }, [timeLimit.min, timeLimit.sec])

    const style = {
        transform: `scale(${scale})`
    }

    return (
        <div className={className} ref={elementRef} style={style}>
            {isStop && (
                <Dialog
                    className="finish-dialog"
                    title="Time's up"
                    text={`Your score is ${hitCount}!`}
                    buttonName='finish'
                    callback={initializeGame}
                />
            )}
            {!isStart && (
                <Dialog
                    className="start-dialog"
                    title="Whack a Mole"
                    text="Click the button to start!"
                    buttonName="start"
                    callback={startGame}
                />
            )}
            <div className="game-status">
                <Display className="time-limit" title="Time limit" text={`${formatTime(timeLimit.min)}:${formatTime(timeLimit.sec)}`} />
                <Display className="score" title="Score" text={hitCount} />
            </div>
            <div className="stage">
                {moles && moles.map((mole, i) => 
                    <div className="cell" key={i}>
                        <div className="hole-mask">
                            <div className="hole">
                                {mole.isStruck &&
                                    <TextEmergeEffect className="hit-effect" text="hit!" />
                                }
                                <Mole className="mole" mole={mole} onClick={handleHit} />
                            </div>
                        </div>
                    </div>
                )}
            </div>
        </div>
    )
}

const StyledWhackAMoleComponent = styled(WhackAMoleComponent)`
    display: flex;
    justify-content: space-between;
    flex-direction: column;
    padding: 20px;

    & .game-status {
        color: white;
        display: flex;
        font-size: .4em;
        justify-content: space-between;
        margin-bottom: 1.6em;
    }

    & .stage {
        background-color: #96D65E;
        display: flex;
        flex-wrap: wrap;
        padding: 0 40px 40px;
        position: relative;
        width: 500px;

        & .cell {
            display: flex;
            align-items: end;
            justify-content: center;
            width: calc(100% / 3);
        }
        & .hole {
            background-color: #431F07;
            border-radius: 100%;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            height: 100px;
            width: 88%;

            &-mask {
                border-radius: 50%;
                display: flex;
                align-items: end;
                justify-content: center;
                overflow: hidden;
                padding-top: 40px;
                position: relative;
                width: 100%;
            }
        }
        & .hit-effect {
            font-size: 2em;
        }
    }
`

const WhackAMole = props => (
    <StyledWhackAMoleComponent {...props} />
)

export default WhackAMole

このコンポーネントのレンダリングが完了すると、まずuseEffectフックによってinitializeGame関数を実行してゲームを初期化します。initializeGameはモグラを叩いた回数hitCount、全てのモグラmolesの状態、制限時間timeLimit、そしてisStart、isStopを初期化します。isStart、isStopはそれぞれゲームを開始・終了しているかどうかを示します。

次に、useElementScaleフック内のuseEffectフックからscaleとrefを取得し、DOMノード.whack-a-moleのref属性にelementRefを、style属性にtransformのscale値にscaleを当てはめたスタイルを設定してアプリのリサイズが行われるようにします。

また、第二引数として[timeLimit.min, timeLimit.sec]を渡したuseEffectフックによって、timeLimit.minかtimeLimit.secが変わる度、つまり制限時間がカウントダウンされる度にタイムアップしたか否かを判定し、タイムアップしていればstopGame関数を実行してゲームを止めます。

WhackAMoleコンポーネントはダイアログDialog、ステータス.game-status、ステージ.stageで構成されています。

Dialogのレンダリングの際はページ読み込み後(!isStart)とゲームが止まったとき(isStop)で表示を変えます。 ページ読み込み後のダイアログのスタートボタンをクリックすると、startGame関数が呼ばれてゲームが開始します。startGame関数はタイマーをスタートさせてモグラを動かす処理を行っています。 ゲームストップ時のダイアログのフィニッシュボタンをクリックすると、initializeGame関数が呼ばれて初期画面に戻ります。

ステータスのタイムを出力する際はformatTime関数でタイムをゼロ埋めします。

ステージにMoleを配置する際はonClickプロパティにhandleHit関数を設定することでモグラをクリックで叩くことが可能になります。handleHit関数はクリックした.moleのクラス名から番号のみを抽出して、それをhitMoleを呼び出す際に引数として渡します。同時にhitCountをカウントアップします。また、Moleコンポーネントと同じ階層にmole.isStruckがtrueのとき、つまりモグラを叩いたときのみTextEmergeEffectコンポーネントがレンダリングされるようにします。

App

ルートのコンポーネントとなるAppコンポーネントを作成します。 App.js

import React from 'react'
import WhackAMole from './components/WhackAMole'
import './App.css'

const App = () => (
    <div className="App">
        <WhackAMole className="whack-a-mole" />
    </div>
)

export default App

ルートコンポーネントにはWhackAMoleコンポーネントを読み込んで配置します。

index.jsを作成する

Reactアプリを実行する際に一番最初に呼び出されるindex.jsファイルを作成します。 index.js

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'

ReactDOM.render(<App />, document.getElementById('root'))

ReactDOM.renderでAppコンポーネントをレンダリングします。

以上でモグラ叩きは完成です。改善点としては、難易度によってモグラが上下に動くスピードやモグラが一度に出現する数を変えると尚良いでしょう。

参考文献