React+Canvasベースのゲームエンジンを作ってみた
React+CanvasベースのゲームエンジンBitBox
ゲームエンジンを作ることになったきっかけは、React 製の自作Webページにテトリス風ゲームを作って公開したことでした。公開当初はReactコンポーネントだけで作ってみたのですが状態管理がうまくいかず途中から Canvas ベースで作り直すことになりました。ブラウザゲームの開発と言えば Canvas を使うのが定番だと思いますが、React のページで Canvas ゲーム動かすような記事やサンプルがほとんど見つからず作るのに苦労しました。React と Canvas の連携についていろいろと試行錯誤を重ねているうちになんとなくゲームエンジンの骨格みたいなものが見えてきたので共通機能を切り出しゲームエンジン化しました。
BitBoxで何ができる
まだまだ開発中なので現状はゲームループの制御に毛が生えた程度の機能しか持っていません。それでもライフゲームのようなシンプルなピクセルゲームであればと簡単に作ることができます。おもな機能は以下になります。
- requestAnimationFrameを使ったゲームループの制御
- フレームスキップ機能
- updateとdraw関数の制御
- ページの幅に合わせてCanvasの描画領域を自動で設定する機能
- キーボード入力を検知する機能
- ピクセルデータの変換(回転や反転など)
BitBoxの特徴
BitBox は100行に満たないシンプルなゲームエンジンであるにもかかわらず update と draw 関数に処理を書くだけで簡単にピクセルゲームを作ることができるのが特徴です。また React と Canvas を組み合わせて開発しているので以下のような他のゲームエンジンにはない特徴があります。
- レスポンシブ対応
- ReactでUIを作ることができる
BitBoxを使って開発したゲーム
これまで BitBox を使ってテトリス風ゲームと1Dパックマン的なゲームを開発しました。このぐらいのゲームであればそこそこ簡単に作れます。試しに遊んでみてください。
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 }
}
参考
-
c 2022 前田デザイン室. ↩︎
Discussion