情報系大学生がweb系に興味を持ってReact+TSでゲーム開発してみた

48456 ワード

自己紹介

初めまして、kado17です!!
現在、東洋大学に所属している大学生で、個人的にReact+TypeScriptを日々勉強中です。
プログラミングは高校生のころからCPythonの基礎的な部分に触れていましたが、本格的に学び始めたのは大学生になってからです。

大学の講義で学んだこと

大学の一年次では、PythonJavascriptCSSについて学び、実際にチームでDjangoを用いたWebページの開発を行いました。
特にチーム開発は個人開発とは違った難しさがあった分、多くの学びを得られました。

React+TSと出会ったきっかけ

以前から、TypeScriptに興味がありましたが、なかなか手をつけられずにいました。
しかし、大学でTypeScriptを学ぶ非公式サークルが出来たことをきっかけに、
サークルに参加して、React+TSに触れるようになりました。

React+TSでゲームを作った理由

デザインやルールが決まっていてコーディングに注力できるため、フレームワークの使い方アルゴリズムの構築技能を習得できると聞いたからです。
また、ゲームはユーザの操作によって画面が大量に変化していくため、SaaSの管理画面設計の基礎を学ぶことが出来ることも利点として挙げられます。

マインスイーパーの解説

大学の冬休み中にマインスイーパーを製作しました。
マインスイーパー
マインスイーパーの画面

操作方法:主に左クリックで操作、右クリックで旗を立てる

この製作では、まずReact+TSのプログラミングに慣れることや、cssスプライトを使用することを主な目的としました。

マインスイーパーの盤面の管理

https://github.com/kado17/Minesweeper/blob/main/pages/index.tsx#L143

マインスイーパーの盤面の管理には、二次元配列とReact Hooksを用いました。(React Hooksに関しては後の項目にて)
配列の初期値をすべて9(未開放のマス)にしています。

  const createBoard = (width: number, height: number): number[][] =>
    Array.from(new Array(height), () => new Array(width).fill(9))

爆弾の生成

最初にクリックしたマスに爆弾が配置されないようにするため、爆弾の生成は最初にマスをクリックした段階で行う仕様になっています。

https://github.com/kado17/Minesweeper/blob/main/pages/index.tsx#L165
//爆弾の生成関数
//x, yにはそれぞれクリックしたマスの座標を代入
const createBomb = (x: number, y: number) => {
    const tmpBombs: { x: number; y: number }[] = []
    while (tmpBombs.length < gameConfig.numberOfBombs) {
        const randomX = Math.floor(Math.random() * gameConfig.widthBlocks)
        const randomY = Math.floor(Math.random() * gameConfig.heightBlocks)
        if (
            !tmpBombs.some((b) => b.x === randomX && b.y === randomY) &&
            x !== randomX &&
            y !== randomY
        ) {
            tmpBombs.push({ x: randomX, y: randomY })
        }
    }
      return tmpBombs
}

空白マス連鎖

マインスイーパーには、クリックしたマスの周りに爆弾がない場合にマスが連鎖して開かれるという仕様があります。
実装にあたっては、連鎖するマスの座標を配列に追加し続けて、配列に格納された座標のマスを開く処理を順番に行う方法を用いました。

https://github.com/kado17/Minesweeper/blob/main/pages/index.tsx#L234
if (existsBomb) {
    //敗北処理
    //~~(一部省略)~~
} else {
    newNum = countBombsAround(x, y, newBombs)
    newBoard[y][x] = newNum
    //クリックしたマスの周りに爆弾がない場合
    if (newNum === 0) {
        let followNewNum = 0
        let newFlagCount = flagCount
        // クリックしたマスの周囲の座標を配列に格納
        const wipBlock = getBlockAround(x, y)
        for (const wip of wipBlock) {
            if (newBoard[wip.y][wip.x] === 12) {
                newFlagCount++
        }
        followNewNum = countBombsAround(wip.x, wip.y, newBombs)
        newBoard[wip.y][wip.x] = followNewNum
        //処理したマスの値が空白かつwipBlockに未格納なら、その周囲の座標を配列に格納
        if (followNewNum === 0) {
            for (const block of getBlockAround(wip.x, wip.y)) {
                if (!wipBlock.some((w) => w.x === block.x && w.y === block.y)) {
                    wipBlock.push({ x: block.x, y: block.y })
                }
            }
        }
    }
    setFlagCount(newFlagCount)
}
    //~~(一部省略)~~
setBoard(newBoard)
  • 関数
    countBombsAround() :代入した座標の周りにある爆弾の数を返す。
    getBlockAround() :代入した座標の周りのマスの座標を配列で返す。

React Hooks の活用

マインスイーパーの製作では、useState, useEffectを使用しました。
useStateは、主にゲームクリアの状態や、盤面の状況などを管理するために用いました。
例:

    // 爆弾の座標を格納した配列の管理
    const [bombs, setBombs] = useState(startBombs)
    //経過時間の管理
    const [timer, setTimer] = useState(0)

useEffectは、タイマー管理に用いました。setInterval()を使うことで毎秒timerの値が1増えるようになっています。

useEffect(() => {
    //爆弾が生成されている(ゲームが始まっている)かつゲームが終わっていないときにTimer稼働
    if (bombs.length !== 0 && !gameState.isGameclear && !gameState.isGameover) {
        const id = setInterval(() => {
        setTimer((t) => t + 1)
    }, 1000)
        return () => {
            clearInterval(id)
        }
    }
}, [bombs, gameState])

反省点

  • 変数名が何を表しているのかが分かりづらい。
  • cssの記述が冗長になっている。
  • マルチデバイス対応していない。

テトリスの解説

大学の春休みにテトリスを製作しました。
テトリス
テトリスの画面

操作方法

  • キーボード
    • 「←, →」で左右移動。
    • 「↓」でテトリミノを下まで落下。
    • 「↑」でテトリミノを回転。
    • 「x」で一時停止・再開。
  • ボタン
    • 各ボタンをクリックして操作。
  • 5ライン消すごとにテトリミノの落下速度が上がります。

この製作では、useEffectを用いたタイマーが直接ゲームに関わるため、プログラミング能力の向上や、useMemouseCallbackの使い分けを学ぶことを主な目的としました。

テトリスの盤面の管理

テトリスの盤面は、二次元配列で管理されています。
実際に描画される盤面は、この二次元配列に操作中のテトリミノを重ねたものになります。

https://github.com/kado17/tetris/blob/main/pages/index.tsx#L231
  • 変数
    board:テトリスの盤面を管理する二次元配列。9の値は壁を表す。
    tetromino,tetrominoX,tetrominoY:テトリミノとそのX座標とY座標をそれぞれ管理する。
  • 関数
    overlayBoard():boardとテトリミノを重ねた二次元配列を返す。
    viewBoard():overlayBoard()から描画する部分を切り取る。

テトリミノの衝突検知

引数にテトリミノの座標やテトリミノそのものを渡すことで、テトリミノが他のテトリミノと重なっていないかや、boardからはみ出ていないかを確認することが出来ます。
返り値は、問題があればfalse, 特になければtrueを返します。

一つの関数内の処理が多くなってしまい、複雑になってしまったことが反省点です。

https://github.com/kado17/tetris/blob/main/pages/index.tsx#L364
const checkCollision = (
    minoX: number,
    minoY: number,
    mino: number[][],
    isCheckOverheight = false,
    chackTargetBoard = board
  ) => {
    if (isCheckOverheight) {
      //テトリミノがy方向にgameBoardをはみ出していないかチェック
      if (GameBoardheight < minoY + mino.length) {
        return false
      }
    }
    //テトリミノがx方向にgameBoardをはみ出していないかチェック
    if (minoX < 0 || GameBoardwidth < minoX + mino[0].length) {
      return false
    }
    //テトリミノとboardを重ねて問題がないかチェック
    const newBoard: number[][] = JSON.parse(JSON.stringify(chackTargetBoard))
    for (let y = 0; y < mino.length; y++) {
      for (let x = 0; x < mino[y].length; x++) {
        if (mino[y][x] > 0 && newBoard[y + minoY][x + minoX] > 0) {
          return false
        }
      }
    }
    return true
}

checkCollision()の使用例

(現在のx座標 + 1)をcheckCollision()の引数にすることで、テトリミノが右に移動ができるかチェックを行います。帰り値がtrueならば移動し、falseならばそのままになります。

const moveRight = () => {
    setTetrominoX((e) =>
      checkCollision(e + 1, tetrominoY, tetromino.block[tetromino.angle]) ? e + 1 : e
    )
  }

テトリミノの回転

テトリミノの回転処理に関しても、moveRight()と同じように回転後に問題がないかを確認して、から回転を行います。

しかし、図のようなテトリミノが壁に隣り合ったうで、回転前よりも回転後のテトリミノの横の長さが大きい場合、①のように回転がうまく行えない場合があります。
その場合、事前に調整を行うことで②のように回転ができるようにしています。

テトリミノ回転

https://github.com/kado17/tetris/blob/main/pages/index.tsx#L396
const rotate = () => {
    if (noOperationCount > 1) {
      return
    }
    let adjustX = 0
    const nowMino = tetromino.block[tetromino.angle]
    const rotatedAngle = rotateAngleRight(tetromino.angle)

    //回転に伴う位置調整の確認
    //長い棒のテトリミノのみ
    if (nowMino.flat().some((n) => n === 1) && tetromino.angle % 2 === 1) {
      if (!checkCollision(tetrominoX + 1, tetrominoY, nowMino)) {
        adjustX = tetromino.angle === 1 ? -1 : -2
      } else if (!checkCollision(tetrominoX - 1, tetrominoY, nowMino)) {
        adjustX = tetromino.angle === 1 ? 2 : 1
      }
    }
    //長い棒と正方形以外
    else if (nowMino.flat().some((n) => n !== 2)) {
      //現在のテトリミノの一番左列がすべて0(空欄)かどうか
      if (nowMino.map((item) => item[0]).every((value) => value === 0)) {
        if (!checkCollision(tetrominoX - 1, tetrominoY, nowMino)) {
          adjustX = 1
        }
      }
      //現在のテトリミノの一番右列がすべて0(空欄)かどうか
      else if (nowMino.map((item) => item.slice(-1)[0]).every((value) => value === 0)) {
        if (!checkCollision(tetrominoX + 1, tetrominoY, nowMino)) {
          adjustX = -1
        }
      }

    //回転後に問題がないか確認
    if (checkCollision(tetrominoX + adjustX, tetrominoY, tetromino.block[rotatedAngle], true)) {
      setTetrominoX((e) => e + adjustX)
      setTetromino({ ...tetromino, angle: rotatedAngle })
    }
}

回転のためのテトリミノの管理方法

最初は、テトリミノの配列そのものを回転する方法を考えていましたが、テトリミノによって回転の際に異なる処理が必要になったため、事前にテトリミノの全パターンを配列に登録する方法を用いました。

//例:長い棒のテトリミノ
    [
      [[0, 0, 0, 0], [1, 1, 1, 1]],
      [[0, 0, 1],[0, 0, 1],[0, 0, 1],[0, 0, 1]],
      [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 1]],
      [[0, 1],[0, 1],[0, 1],[0, 1]],
    ],

反省点

  • プログラムが複雑になっている。
    • 特にメインループは一見何をしているのかが分からない。
    • 関数も引数があるものとないものが混ざってしまっている。

やっていないこと

ステート管理やAtomic Design、環境構築、Dockerはやっていません。
環境構築については、有識者の方にご協力いただきました。

今後の展望

現在大学でCを学び始めたため、ReactとCを組み合わせて何かできないかを模索中です。
他にも、環境構築から開発を進めたり、夏休みを使ってIT企業でWeb系のReact+TSのアルバイトをしてみたいと考えています。

おわりに

ここまで読んでいただきありがとうございます!!
ご用件がございましたらTwitterにDMをいただけると幸いです。