ツクログネット

Reactでモグラ叩きを作る

eye catch

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

Demo

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

開発環境を構築する

Reactアプリの開発環境の構築についてはReactの開発環境を構築するをご覧ください。

ディレクトリ構成

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、index.jsの内容はプロジェクト作成時のままです。

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にアプリケーション内で使用する定数を定義します。通常、定数は大文字とアンダーバーで定義されます。

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は叩かれたときのモグラの状態を示します。

ユーティリティ関数を定義する

utils.jsに下記のユーティリティ関数を定義します。

utils.js

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

createArray関数は要素elemとその個数lengthを受け取り、length個のelemが格納された配列を返します。

GSAPをインストールする

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

yarn add 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」を下記のコマンドでインストールします。

yarn add 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コンポーネントを読み込んで配置します。

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

参考文献