React × TypeScript で簡単なゲームを作ってみた

---
## はじめに
普段仕事にVue.jsを使っているのですが、書籍でReactを学習したので確認のためにゲームを作りました。ボールを跳ね返すだけゲームですが、学習した内容を盛り込む事ができました。
https://github.com/tjrit17/squash.git


## 目次
[1.ゲームの説明](#1-ゲームの説明)
[2.環境構築](#2-環境構築)
[3.フォルダ構成](#3-フォルダ構成)
[4.コンポーネント](#4-コンポーネント)
[5.カスタムフック](#5-カスタムフック)
[6.参考](#6-参考)
[7.最後に](#7-最後に)

# 1. ゲームの説明
STARTボタンをクリックして新規ゲームを開始します。
STOPボタンをクリックしてゲームを中断します。
＜、＞ボタンをクリックしてラケットを左右に移動します。
ラケットにボールがあたるように左右に動かしてください。
ボールが一番下に来た時にその下にラケットがなければ終了です。
![squash_screen_img.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2563976/8aec7f53-bf07-dbea-978a-121453d3e799.gif)

# 2. 環境構築
#### 前提条件
Node.jsとnpmがインストール済であること。
#### インストール
squashという名前でReact + Typescript の開発環境を構築します。
```text:
$ npx create-react-app squash --template typescript
$ cd squash
$ npm i
```
#### styled-componentsのインストール
ReactはStyleの定義方法がいくつかあるので、今回はコンポーネントのようにタグを作成してスタイルを定義する方法を採用しました。
```text:
$ npm install -D styled-components
$ npm install -D @types/styled-components
```

# 3. フォルダ構成
srcフォルダの直下にcomponents、hooks、constants.ts、index.tsxを配置しています。
src/
　├ components/
　│　├ common/
　│　│　└ BasicButton.tsx (ベーシックボタンコンポーネント)
　│　├ unique/
　│　│　├ BallElement.tsx (ボールエレメントコンポーネント)
　│　│　├ CourtAria.tsx (コートエリアコンポーネント)
　│　│　├ PanelAria.tsx (パネルエリアコンポーネント)
　│　│　├ RacketAria.tsx (ラケットエリアコンポーネント)
　│　│　└ TitleAria.tsx (タイトルエリアコンポーネント)
　│　└ App.tsx (メインコンポーネント)
　├ hooks/
　│　├ useBallCtl.ts (ボール位置コントロール)
　│　├ useRacketCtl.ts (ラケット位置コントロール)
　│　└ useStartStopCtl.ts (スタート・ストップコントロール)
　├ constants.ts (画面パーツのサイズと色の設定)
　└ index.tsx (エントリーポイント)

#### components
コンポーネントは図のように分割しました。ボタンは汎用的なコンポーネントとして別フォルダにしています。
![squash_component.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2563976/ff1ea428-f8f9-ea73-59e2-c8c9e8e824d5.gif)
#### hooks
カスタムフックを置くフォルダです。関連するState、関数を１つのファイルにまとめたもので、コンポーネントから呼び出して使う事ができます。
useBallCtl.ts： ボールの動き、跳ね返り、ラケットのアタリ判定の計算
seRacketCtl.ts：ラケット位置のStateを定義
useStartStopCtl.ts：Start,stop時の動作の計算
#### constants.ts
　画面サイズと色を定義しているファイルです。
![squash_constants.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2563976/d3b9ffdd-7c22-6f99-017f-f969f4ff21c8.gif)
```react:constants.ts
// 画面パーツのサイズを指定
export class SIZES_PX {
  static readonly COURT_WIDTH = 340
  static readonly COURT_HIGHT = 270
  static readonly BALL_DISP = 22
  static readonly WALL_THICKNESS = 7
  static readonly RACKET_WIDTH = 100
  static readonly RACKET_MOVE = 25
  static readonly BUTTON_WIDTH = 70
  static readonly BALL_BORADER = 2
  static readonly BALL_OUTLINE = this.BALL_DISP + this.BALL_BORADER * 2
  static readonly RACKET_MV_WIDTH = this.COURT_WIDTH - this.RACKET_WIDTH
  static readonly BALL_MV_WIDTH = this.COURT_WIDTH - this.BALL_OUTLINE
  static readonly BALL_MV_HEIGHT =
    this.COURT_HIGHT - this.BALL_OUTLINE + this.BALL_BORADER * 3 - 1
}
// 色を指定
export class COLORS {
  static readonly BALL = '#8fce48'
  static readonly RACKET = '#40608a'
  static readonly TITLE_BACK = '#40608a'
  static readonly TITLE_TEXT = '#CCCCCC'
  static readonly WALL = '#6b4b21'
  static readonly FLOOR = '#f5eed9'
}
// ボール移動のタイマー設定値（ミリ秒単位）
export class OTHERS {
  static readonly INTERVAL = 5
}
```
#### index.tsx
　このアプリのエントリーポイントです。
```react:index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import { App } from './components/App'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)
```
# 4. コンポーネント
#### メインコンポーネント

使用するコンポーネントとカスタムフックをimportします。
```react:App.tsx
import { VFC, useEffect } from 'react'
import styled from 'styled-components'
import { SIZES_PX, COLORS, OTHERS } from './../constants'
import { TitleAria } from './unique/TitleAria'
import { CourtAria } from './unique/CourtAria'
import { RacketAria } from './unique/RacketAria'
import { PanelAria } from './unique/PanelAria'
import { useBallCtl } from './../hooks/useBallCtl'
import { useStartStopCtl } from './../hooks/useStartStopCtl'
import { useRacketCtl } from '../hooks/useRacketCtl'
```
３つのカスタムフックを呼び出します。フック内で定義したStateや関数が使えるようになります。
```react:
export const App: VFC = () => {
  const { raketPosX, stRaketPosX } = useRacketCtl()
  const { ballColor, startFlag, stStartFlag } = useStartStopCtl()
  const { ballPos, calcMvBallPos } = useBallCtl()
```
  一定時間が経過する毎にcalcMvBallPos()を呼び出しています。ここでボールを動かしています。
```react:
  useEffect(() => {
    const interval = setInterval(() => {
      if (startFlag) {
        calcMvBallPos(raketPosX, stStartFlag)
      }
    }, OTHERS.INTERVAL)
    return () => clearInterval(interval)
  })
```
順番にコンポーネントを呼び出します。
```react:
  return (
    <SMainContainer>
      <TitleAria>SQUASH</TitleAria>
      <CourtAria ballPos={ballPos} ballColor={ballColor} />
      <RacketAria raketPosX={raketPosX} />
      <PanelAria
        raketPosX={raketPosX}
        stRaketPosX={stRaketPosX}
        startFlag={startFlag}
        stStartFlag={stStartFlag}
      />
    </SMainContainer>
  )
}
const SMainContainer = styled.div`
  margin: 0px;
  padding: 0px;
  width: ${SIZES_PX.COURT_WIDTH}px;
  border: ${SIZES_PX.WALL_THICKNESS}px solid ${COLORS.WALL};
  background-color: ${COLORS.WALL};
`
```

#### タイトルエリアコンポーネント
一番上の「SQUASH」というタイトルを表示します。

```react:TitleAria.tsx
import { VFC, memo } from 'react'
import styled from 'styled-components'
import { SIZES_PX, COLORS } from '../../constants'

type Props = {
  children: React.ReactNode
}
```
このコンポーネントは最初にしか表示しないので、再レンダリングされないようにmemo()で囲んでいます。
```react:
export const TitleAria: VFC<Props> = memo((props) => {
  const { children } = props

  return <STitleAria>{children}</STitleAria>
})

const STitleAria = styled.h1`
  width: 100%;
  margin: 0px auto 0px auto;
  background-color: ${COLORS.TITLE_BACK};
  border-bottom: ${SIZES_PX.WALL_THICKNESS}px solid ${COLORS.WALL};
  box-shadow: 2px 2px 2px 0px rgba(255, 255, 255, 0.25) inset,
    -2px -2px 2px 0px rgba(0, 0, 0, 0.3) inset;
  text-align: center;
  color: ${COLORS.TITLE_TEXT};
  text-shadow: 0.04em 0.02em 0 #b0bec5, 0.08em 0.05em 0 rgba(0, 0, 0, 0.6);
`
```

#### コートエリアコンポーネント
ボールが動き回る領域を表示します。
この中でボールを表示するコンポーネントを呼び出します。

```react:CourtAria.tsx
import React, { VFC } from 'react'
import styled from 'styled-components'
import { SIZES_PX, COLORS } from '../../constants'
import { BallElement } from './BallElement'

type Props = {
  ballPos: { x: number; y: number }
  ballColor: string
}

export const CourtAria: VFC<Props> = (props) => {
  const { ballPos, ballColor } = props

  return (
    <SCourtAria>
      <BallElement ballPos={ballPos} ballColor={ballColor} />
    </SCourtAria>
  )
}

const SCourtAria = styled.div`
  width: 100%;
  height: ${SIZES_PX.COURT_HIGHT}px;
  margin: 0px auto 0px auto;
  background-color: ${COLORS.FLOOR};
  display: flex;
`
```
#### ボールエレメントコンポーネント
動き回るボールを表示します。
```react:BallElement.tsx
import { VFC } from 'react'
import styled from 'styled-components'
import { SIZES_PX, COLORS } from '../../constants'

type Props = {
  ballPos: { x: number; y: number }
  ballColor: string
}

export const BallElement: VFC<Props> = (props) => {
  const { ballPos, ballColor } = props

  const BallElementStyle = {
    transform: `translateX(${ballPos.x}px) translateY(${ballPos.y}px)`,
    background: ballColor,
  }

  return <SBallElement style={BallElementStyle} />
}

const SBallElement = styled.div`
  width: ${SIZES_PX.BALL_DISP}px;
  height: ${SIZES_PX.BALL_DISP}px;
  background-clip: padding-box;
  border-radius: ${SIZES_PX.BALL_OUTLINE / 2 + 3}px;
  border: ${SIZES_PX.BALL_BORADER}px solid ${COLORS.FLOOR};
  margin: 0px auto auto 0px;
`
```
#### ラケットエリアコンポーネント
ラケットとラケットが移動する領域を表示します。
```react:RacketAria.tsx
import { VFC, memo } from 'react'
import styled from 'styled-components'
import { SIZES_PX, COLORS } from '../../constants'

type Props = {
  raketPosX: number
}
```
ラケットが静止している時にレンダリングされないようにmemo()で囲んでいます。
```react:
export const RacketAria: VFC<Props> = memo((props) => {
  const { raketPosX } = props
  const SRacketBarStyle = {
    transform: `translateX(${raketPosX}px) `,
  }
  return (
    <SRacketAria>
      <SRacketBar style={SRacketBarStyle} />
    </SRacketAria>
  )
})

const SRacketAria = styled.div`
  width: 100%;
  height: 20px;
  margin: 0px auto 0px auto;
  padding-top: 3px;
  background-color: ${COLORS.FLOOR};
`
const SRacketBar = styled.div`
  width: ${SIZES_PX.RACKET_WIDTH}px;
  height: 10px;
  background-color: ${COLORS.RACKET};
  box-shadow: 1px 1px 1px 0px rgba(255, 255, 255, 0.75) inset,
    -1px -1px 1px 0px rgba(0, 0, 0, 0.3) inset;
`
```
#### パネルエリアコンポーネント
操作パネルを表示し、ボタンを押した時の処理をします。
ベーシックボタンコンポーネントをimportします。
```react:PanelAria.tsx
import { VFC, useCallback, memo } from 'react'
import styled from 'styled-components'
import { SIZES_PX, COLORS } from '../../constants'
import { BasicButton } from '../common/BasicButton'
```
メインコンポーネントからPropsとして以下のStateと関数を受け取ります。
State
　ラケット位置
　開始フラグ
関数
　ラケット位置の設定関数
　開始フラグの設定関数
```react:
type Props = {
  raketPosX: number
  stRaketPosX: (n: number) => void
  startFlag: boolean
  stStartFlag: (bl: boolean) => void
}
```
各ボタンが押された時に処理するメソッドを記述します。
- onClickLeftBtn：「＜」ボタンが押された時の処理
　左端で止まるようにします。
- onClickRightBtn：「＞」ボタンが押された時の処理
　右端で止まるようにします。
- onClickStart ：「START」ボタンが押された時の処理
　startFlagにtrueをセットします。
- onClickStart ：「STOP」ボタンが押された時の処理
　startFlagにfalseをセットします。

memo()とuseCallback()を使ってボタンが押されていない時にレンダリングされないようにします。
```react:
export const PanelAria: VFC<Props> = memo((props) => {
  const { raketPosX, stRaketPosX, startFlag, stStartFlag } = props

  const onClickLeftBtn = useCallback(() => {
    if (startFlag) {
      stRaketPosX(
        raketPosX - SIZES_PX.RACKET_MOVE < 0
          ? 0
          : raketPosX - SIZES_PX.RACKET_MOVE
      )
    }
  }, [raketPosX, stRaketPosX, startFlag])

  const onClickRightBtn = useCallback(() => {
    if (startFlag) {
      stRaketPosX(
        raketPosX + SIZES_PX.RACKET_MOVE > SIZES_PX.RACKET_MV_WIDTH
          ? SIZES_PX.RACKET_MV_WIDTH
          : raketPosX + SIZES_PX.RACKET_MOVE
      )
    }
  }, [raketPosX, stRaketPosX, startFlag])

  const onClickStart = useCallback(() => {
    stStartFlag(true)
  }, [stStartFlag])

  const onClickStop = useCallback(() => {
    stStartFlag(false)
  }, [stStartFlag])
```
スタイルを指定してボタンコンポーネントを呼び出します。
```react:
 const BasicButtonStyle = {
    sWidth: `${SIZES_PX.BUTTON_WIDTH}px`,
    sMargin: `10px 10px 10px 5px`,
    sBgColor: `#ececec`,
  }

  return (
    <SPanelArea>
      <BasicButton onClickBtn={onClickLeftBtn} {...BasicButtonStyle}>
        {`<`}
      </BasicButton>
      <BasicButton onClickBtn={onClickRightBtn} {...BasicButtonStyle}>
        {`>`}
      </BasicButton>
      <BasicButton onClickBtn={onClickStart} {...BasicButtonStyle}>
        START
      </BasicButton>
      <BasicButton onClickBtn={onClickStop} {...BasicButtonStyle}>
        STOP
      </BasicButton>
    </SPanelArea>
  )
})

const SPanelArea = styled.div`
  width: 100%;
  height: 35px;
  margin: 0px auto 0px auto;
  background: ${COLORS.WALL};
`
```

#### ベーシックボタンコンポーネント
commonフォルダに置いたコンポーネントはいろいろな所から呼び出される事を想定して設計します。
ボタンコンポーネントは以下の項目を引数として呼び出します。
 - 関数：ボタンを押した時に呼び出される関数
 - Style：テキスト、幅、位置、色

```react:BasicButton.tsx
import { VFC, memo } from 'react'
import styled from 'styled-components'

type Props = {
  onClickBtn: () => void
  children: React.ReactNode
  sWidth: string
  sMargin: string
  sBgColor: string
}

export const BasicButton: VFC<Props> = memo((props) => {
  const { onClickBtn, children, sWidth, sMargin, sBgColor } = props
  const sBasicButtonlStyle = {
    width: sWidth,
    margin: sMargin,
    background: sBgColor,
  }

  return (
    <SBasicButton style={sBasicButtonlStyle} onClick={onClickBtn}>
      {children}
    </SBasicButton>
  )
})

const SBasicButton = styled.button`
  font-family: sans-serif;
  font-weight: bold;
  height: 22px;
  padding-top: 0px;
  border: none;
  box-shadow: 2px 2px 2px 0px rgba(255, 255, 255, 0.75) inset,
    -2px -2px 2px 0px rgba(0, 0, 0, 0.3) inset;
`
```


# 5. カスタムフック

#### ボール位置コントロールフック
ボールの動きを計算するためのStateと関数をまとめています。

ボール位置と移動方向のStateを定義しています。
```react:useBallCtl.ts
import { useCallback, useState } from 'react'
import { SIZES_PX } from './../constants'
export const useBallCtl = () => {
  const [ballPos, setBallPos] = useState({ x: 0, y: 0 })
  const [ballDirection, setBallDirection] = useState({ x: 1, y: 1 })
```

ボールの位置をインクリメント（デクリメント）します。
```react:
  const calcMvBallPos = useCallback(
    (raketPosX: number, stStartFlag) => {
      ballPos.x += ballDirection.x
      ballPos.y += ballDirection.y
```
左右の壁にあったたら反転します。
```react:
      if (ballPos.x <= 0 || ballPos.x >= SIZES_PX.BALL_MV_WIDTH) {
        ballDirection.x = ballDirection.x * -1
      }
```
上の壁もしくはラケットにあったたら反転します。
```react:
  const calcMvBallPos = useCallback(
    (raketPosX: number, stStartFlag) => {
      ballPos.x += ballDirection.x
      ballPos.y += ballDirection.y

      if (ballPos.y <= 0) {
        ballDirection.y = ballDirection.y * -1
      } else if (ballPos.y >= SIZES_PX.BALL_MV_HEIGHT) {
        ballDirection.y = ballDirection.y * -1
        if (
          raketPosX > ballPos.x + SIZES_PX.BALL_DISP ||
          raketPosX + SIZES_PX.RACKET_WIDTH < ballPos.x
        ) {
          stStartFlag(false)
        }
      }
```
計算結果を設定し返します。
```react:
      setBallPos({ x: ballPos.x, y: ballPos.y })
      setBallDirection({ x: ballDirection.x, y: ballDirection.y })
    },
    [ballPos, ballDirection]
  )
  return {
    ballPos,
    calcMvBallPos,
  }
}
```

#### ラケット位置コントロールフック
ラケット位置のState定義をします。
同時にその初期値を設定します。

```react:useRacketCtl.ts
import { useCallback, useState } from 'react'
import { SIZES_PX } from './../constants'

export const useRacketCtl = () => {
  const [raketPosX, setRaketPosX] = useState<number>(
    SIZES_PX.RACKET_MV_WIDTH / 2
  )
```
ラケット位置を設定する関数を定義します。
```react:
  const stRaketPosX = useCallback(
    (n: number) => {
      setRaketPosX(n)
    },
    [setRaketPosX]
  )
```
関数とStateを返します。
```react:
  return {
    raketPosX,
    stRaketPosX,
  }
}
```
#### スタート・ストップコントロールフック

ボールの色とスタート・ストップフラグを定義します。
```react:useStartStopCtl.ts
import { useCallback, useState } from 'react'
import { COLORS } from './../constants'

export const useStartStopCtl = () => {
  const [ballColor, setBallColor] = useState(COLORS.BALL)
  const [startFlag, setStartFlag] = useState<boolean>(false)
```
スタート・ストップフラグの値によってボールの色を変えます。
```react:
  const stStartFlag = useCallback(
    (bl: boolean) => {
      if (bl === false) {
        setBallColor('black')
      } else {
        setBallColor(COLORS.BALL)
      }
      setStartFlag(bl)
    },
    [setBallColor, setStartFlag]
  )
```
関数とStateを返します。
```react:
  return {
    ballColor,
    startFlag,
    stStartFlag,
  }
}
```
# 6. 参考

#### 学習した書籍
[<font color="ForestGreen">モダンJavaScriptの基本から始める React実践の教科書 (最新ReactHooks対応)</font><img width = 100 src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2563976/e9126f35-5c40-dfca-3326-c258ffa38171.jpeg">](https://www.sbcr.jp/product/4815610722/)
Reactに必要なjavascriptの機能から、実際の開発に必要な機能まで一通り書かれている良書です。また、対話形式なので飽きずに読み進める事ができます。

#### タイトルとボタンの装飾
[【小ネタ】CSSだけでPhotoshopのベベルの効果を再現する](https://jdash.info/archives/51931401.html)

#### ボールを動かすためのインターバルタイマー
[React hookにおけるTimeoutとTimeInterval【止まらない・重複する・増えない】](https://rios-studio.com/tech/react-hook%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8Btimeout%E3%81%A8timeinterval%E3%80%90%E6%AD%A2%E3%81%BE%E3%82%89%E3%81%AA%E3%81%84%E3%83%BB%E9%87%8D%E8%A4%87%E3%81%99%E3%82%8B%E3%80%91)

# 7. 最後に
最近Vue3・Nuxt3を調べていて、使いやすそうと感じていたのですが、Reactに非常に似ていると感じました。
ボールが壁にあって反転する部分で少し隙間があいてしましました。ボールを動かすと形状がひずむために、2pixelのボーダーを背景色で塗っているためです。ちゃんとしたゲームを作るならcanvasを使い画像として扱うべきだと思いました。