🤖

React+Canvasベースのゲームエンジンを作ってみた

2024/05/03に公開

React+CanvasベースのゲームエンジンBitBox

ゲームエンジンを作ることになったきっかけは、React 製の自作Webページにテトリス風ゲームを作って公開したことでした。公開当初はReactコンポーネントだけで作ってみたのですが状態管理がうまくいかず途中から Canvas ベースで作り直すことになりました。ブラウザゲームの開発と言えば Canvas を使うのが定番だと思いますが、React のページで Canvas ゲーム動かすような記事やサンプルがほとんど見つからず作るのに苦労しました。React と Canvas の連携についていろいろと試行錯誤を重ねているうちになんとなくゲームエンジンの骨格みたいなものが見えてきたので共通機能を切り出しゲームエンジン化しました。

https://github.com/glassonion1/bitbox

BitBoxで何ができる

まだまだ開発中なので現状はゲームループの制御に毛が生えた程度の機能しか持っていません。それでもライフゲームのようなシンプルなピクセルゲームであればと簡単に作ることができます。おもな機能は以下になります。

  • requestAnimationFrameを使ったゲームループの制御
  • フレームスキップ機能
  • updateとdraw関数の制御
  • ページの幅に合わせてCanvasの描画領域を自動で設定する機能
  • キーボード入力を検知する機能
  • ピクセルデータの変換(回転や反転など)

BitBoxの特徴

BitBox は100行に満たないシンプルなゲームエンジンであるにもかかわらず update と draw 関数に処理を書くだけで簡単にピクセルゲームを作ることができるのが特徴です。また React と Canvas を組み合わせて開発しているので以下のような他のゲームエンジンにはない特徴があります。

  • レスポンシブ対応
  • ReactでUIを作ることができる

BitBoxを使って開発したゲーム

これまで BitBox を使ってテトリス風ゲームと1Dパックマン的なゲームを開発しました。このぐらいのゲームであればそこそこ簡単に作れます。試しに遊んでみてください。
https://9revolution9.com/ja/games/tetrimimus/
https://9revolution9.com/ja/games/pac-boy/
https://9revolution9.com/ja/games/eca/

BitBoxのプログラムの構成

BitBox のプログラムの構成は以下の3つになっています。

  • React Hooks 形式のゲームエンジン本体
  • ゲームの状態管理を規定する State インタフェース
  • ゲームの状態を描画するための Renderer インタフェース

ゲーム開発者は Renderer と State インタフェースをそれぞれ実装した GameRenderer と GameState クラスに実際のゲームのプログラムを書いていきます。ゲームのロジックと描画処理を分離するためにあえてゲームの状態を管理するクラスと状態を描画するクラスを分けて開発するスタイルにしています。

クラス図

プログラム構成をクラス図に表すと以下のようになります。GameEngine、Renderer、StateがBitBox本体です。

実装の都合上、Page と useCanvas はただの関数(JSX関数とReact Hooks関数)ですが便宜上クラスとしています。

BitBoxの使い方

キャラクターを動かしてみる

ドット絵のカニ[1]がが左右に動くだけの簡単なサンプルを使って BitBox を使ったプログラムの使い方を解説します。

実際に動くプログラムはこちらになります。左矢印または右矢印キーを押すとカニが左右に移動します(StackBlitzの画面をクリックするとキーボード操作できるようになります)。

キャラクターのデータを用意する

まず初めにカニのキャラクターのデータを用意します。

// カニの色
// #2e2e2e=黒、#e33e12=濃い赤、#fc6b3b=薄い赤
const colors = [
  '未使用',
  '#2e2e2e',
  '#e33e12',
  '#fc6b3b'
]
// カニのアニメーションフレーム
const crabFrames = [
  `
2  1 1  2
22 2 2 22
223333322
  33333
 2333332
 2     2
`,
  `
2  1 1  2
22 2 2 22
223333322
  33333
 2333332
2       2
`
]

アニメーションフレームは文字列の配列で作成します。あとで数値の配列に変換して追加います。

ゲームロジックと描画処理を書いていく

次にゲームロジックを作成します。ゲームロジックは State インタフェースを実装したクラスに書いていきます。

import { Keyboard, State, Renderer, str2matrix } from '@bitbox-js/core'

export class GameState implements State {
  // カニの位置
  x: number
  y: number
  // アニメーションフレーム
  frames: number[][][]
  constructor() {
    this.x = 10
    this.y = 20
    // カニのアニメーションフレームを数字の配列データに変換する
    // カニのアニメーションフレームは文字列のままだと処理ができないのでstr2matrix関数を使って数値配列に変換する
    this.frames = crabFrames.map((frame) => str2matrix(frame))
  }
  frame(): number[][] {
    return this.frames[0]
  }
  // カニが歩くアニメーション
  move():void {
    const first = this.frames.shift()
    if (first) {
      this.frames.push(first)
    }
  }
  // update関数
  // 毎フレームゲームエンジンが実行する
  // ゲームロジックをここに書く
  update(delta: number, key: string) {
    // キーボードの入力
    switch (key) {
      // 左に進む
      case Keyboard.Left:
        this.x -= 1
        this.move()
        break
      // 右に進む
      case Keyboard.Right:
        this.x += 1
        this.move()
        break
    }
  }
}

次に描画処理を作成します。描画処理は Renderer インタフェースを実装するクラスに書いていきます。

export class GameRenderer implements Renderer {
  /*
   * 1ドットの幅
   * BitBox側で値を計算してセットしてくれる
   * 初期値は0にセットしておく
   */
  blockWidth = 0
  // 状態
  state: GameState
  constructor(state: GameState) {
    this.state = state
  }
  // 描画処理をここに書く
  draw(ctx: CanvasRenderingContext2D) {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
    const px = this.state.x
    const py = this.state.y
    // カニを描画する
    this.state.frame().forEach((row, y) => {
      row.forEach((v, x) => {
        if (v > 0) {
          this.drawBlock(ctx, px + x, py + y, v)
        }
      })
    })
  }
  // 1ドット描画する
  private drawBlock(
    ctx: CanvasRenderingContext2D,
    x: number,
    y: number,
    value: number
  ): void {
    let px = x * this.blockWidth
    let py = y * this.blockWidth
    ctx.fillStyle = colors[value]
    ctx.fillRect(px, py, this.blockWidth, this.blockWidth)
  }
}

作成したゲームプログラムをReactのページに描画してみる

最後に作成したサンプルプログラムをReactのページで動くようにします。

import { useCanvas } from '@bitbox-js/core'
import { GameRenderer, GameState } from './game'

const Page = () => {
  // Stateインタフェースを実装するGameStateクラスをインスタンス化する
  const state = new GameState()
  // Rendererインタフェースを実装するGameRendererクラスをインスタンス化する
  const renderer = new GameRenderer(state)
  // ゲームエンジン(100✖️50マスのドット)
  const { canvasRef } = useCanvas(100, 50, renderer)

  return (
    <div className="w-full">
      {/* CSSクラスはTailwindを利用、横幅いっぱい、高さはCanvasの描画領域まで自動で伸長する */}
      <canvas ref={canvasRef} className="w-full h-max" />
    </div>
  )
}

export default Page

参考:ゲームループのプログラム

最後に BitBox のゲームループのプログラムを紹介します。以下の3つの処理に注目しながらコードを読んでもらえると良いかとおもいます。

  • Canvasの描画サイズの計算
  • フレームスキップ処理
  • UpdateとDraw関数の呼び出し
export interface State {
  update(delta: number, key: string): void
}

export interface Renderer {
  blockWidth: number
  state: State
  draw(ctx: CanvasRenderingContext2D): void
}

export const useCanvas = (
  blockSizeX: number,
  blockSizeY: number,
  renderer: Renderer
) => {
  const canvasRef = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    const ctx = canvas.getContext('2d')
    if (!ctx) return

    // 描画サイズの計算
    const displayWidth = canvas.clientWidth
    const blockWidth = Math.floor(displayWidth / blockSizeX)
    const displayHeight = blockWidth * blockSizeY

    const { devicePixelRatio: ratio = 1 } = window

    canvas.width = displayWidth * ratio
    canvas.height = displayHeight * ratio
    ctx.scale(ratio, ratio)

    // desired interval is 60fps
    const interval = 1000.0 / 60
    let lastTime = 0

    let animationFrameId = 0
    let pressedKey = ''
    // ゲームループ
    const gameLoop = (now: number) => {
      if (!lastTime) {
        lastTime = now
      }
      let delta = Math.floor(now - lastTime)

      // フレームスキップ処理
      while (delta >= 0) {
        const diff = delta - interval
        // update関数の呼び出し
        renderer.state.update(diff >= 0 ? interval : delta, pressedKey)
        pressedKey = ''
        delta = diff
      }
      // ブロックサイズをセットする
      renderer.blockWidth = blockWidth
      // draw関数の呼び出し
      renderer.draw(ctx)

      lastTime = now
      animationFrameId = window.requestAnimationFrame(gameLoop)
    }
    window.requestAnimationFrame(gameLoop)

    // キーボードイベントの設定
    const handleKeyDown = (e: KeyboardEvent) => {
      e.preventDefault()
      pressedKey = e.key
    }
    canvas.addEventListener('keydown', handleKeyDown, false)

    return () => {
      window.cancelAnimationFrame(animationFrameId)
      canvas.removeEventListener('keydown', handleKeyDown, false)
    }
  }, [blockSizeX, blockSizeY, renderer])

  return { canvasRef }
}

参考

脚注
  1. c 2022 前田デザイン室. ↩︎

Discussion