🍤

Ebitengine入門

2024/12/19に公開

この記事は、Go Advent Calendar 2024 シリーズ2 19日目の記事です。

https://qiita.com/advent-calendar/2024/go

この記事は、普段Goを書いていてEbitengineに興味はあるけどどこから始めていいか分からない方に向けた記事です。
ただのWindowを表示する簡単なところから、画像を表示して動かすところまでをゆっくり説明していこうと思います。

1. 空のWindowを表示する

まず最初の一歩としてEbitengineを使って空のWindowを表示してみます。

今回はWindowを表示するだけですが、追い追いこのWindowの中に画像を表示していくことになります。これはそのための第一歩です。

Ebitengine を動かすために最低限必要なのは ebiten.Game というインターフェースを実装したポインタを ebiten.RunGame() に渡すことです。この ebiten.Game は次のように定義されていて、次の3つのメソッドを実装している必要があります。

type Game interface {
	Update() error
	Draw(screen *ebiten.Image)
	Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)
}

このインターフェースを満たす最小の実装が次の例になります。コメントも含めて全部で26行程度なのでとても短いですね!

main.go
package main

import (
	"github.com/hajimehoshi/ebiten/v2"
)

type Game struct{}

// Updateはゲームを1ティック更新します
func (g *Game) Update() error {
	// ゲームロジックをここに書きますが、今はまだ何もしません
	return nil
}

// Draw はゲーム画面を1フレーム分描画します
func (g *Game) Draw(screen *ebiten.Image) {
	// 今はまだ何も描画しないので空のままにしておきます
}

// Layout はゲームの論理的な画面サイズを返します
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return outsideWidth, outsideHeight
}
func main() {
	ebiten.RunGame(&Game{})
}

これを実行すると次のようなWindowが表示されます。
これで最初の一歩を踏み出せました!🎉

ソースの全体像は下記からも確認できます。

https://github.com/demouth/learning-ebitengine/tree/main/000

2. 画像を表示する

ここまでで何も描画されていない真っ黒なWindowを表示できたので、次にここに画像を表示してみます。

まず pumpkin.png というファイル名のこんなpng画像を用意して main.go と同じディレクトリに配置しました。これを先ほどのWindowの中に描画してみようと思います。

pumpkin.png

まずは最終的なソースを紹介します(左側に+と書いてある行が追加された行です)。ちょっと行数が増えてしまいましたが順を追って説明していきます。

main.go
  package main

  import (
+  	"bytes"
+ 	_ "embed"
+ 	"image"
+ 	_ "image/png"
+
  	"github.com/hajimehoshi/ebiten/v2"
  )

+ var (
+ 	//go:embed pumpkin.png
+ 	pumpkinPng   []byte
+ 	pumpkinImage *ebiten.Image
+ )

  type Game struct{}

  // Updateはゲームを1ティック更新します
  func (g *Game) Update() error {
  	// ゲームロジックをここに書きますが、今はまだ何もしません
  	return nil
  }

  // Draw はゲーム画面を1フレーム分描画します
  func (g *Game) Draw(screen *ebiten.Image) {
+ 	op := &ebiten.DrawImageOptions{}
+ 	screen.DrawImage(pumpkinImage, op)
  }

  // Layout はゲームの論理的な画面サイズを返します
  func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
  	return outsideWidth, outsideHeight
  }
  func main() {
+ 	// png画像を *ebiten.Image に変換します
+ 	img, _, _ := image.Decode(bytes.NewReader(pumpkinPng))
+ 	pumpkinImage = ebiten.NewImageFromImage(img)
  	ebiten.RunGame(&Game{})
  }

それでは差分をひとつずつ確認していきましょう。

まずはじめに go:embed という仕組みを使って pumpkinPng という変数に先ほどのpng画像を埋め込んでいます。これによりプログラムを実行した時には pumpkinPng の中にpng画像が入っている状態になります。
また、この後で説明しますが pumpkinImage という変数も定義しています。

+ var (
+ 	//go:embed pumpkin.png
+ 	pumpkinPng   []byte
+ 	pumpkinImage *ebiten.Image
+ )

そして main 関数の中で埋め込んだpng画像( pumpkinPng )を読み込み *ebiten.Image に変換します。それを pumpkinImage に代入しています。

  func main() {
+ 	// png画像を *ebiten.Image に変換します
+ 	img, _, _ := image.Decode(bytes.NewReader(pumpkinPng))
+ 	pumpkinImage = ebiten.NewImageFromImage(img)
  	ebiten.RunGame(&Game{})
  }

最後に Draw メソッドの中で pumpkinImage を画面に表示します。
今回は画像をそのまま表示できればいいので ebiten.DrawImageOptions{} には何も設定を指定しませんが、このあと描画位置を変更したりするので、その時に色々と設定を追加していく事になります。

  // Draw はゲーム画面を1フレーム分描画します
  func (g *Game) Draw(screen *ebiten.Image) {
+ 	op := &ebiten.DrawImageOptions{}
+ 	screen.DrawImage(pumpkinImage, op)
  }

これを実行すると次のように表示されます。
画面の左上に画像が表示されました!🎉

ソースの全体像は下記からも確認できます。

https://github.com/demouth/learning-ebitengine/tree/main/001

3. 画像を回転させる

ここまでで画像を表示できるようになりましたが、次はこの画像を回転させてみます。
まず最初にソース全体の差分を紹介します(左側に+と書いてある行が追加された行で、-と書いてある行が削除された行です)。

main.go
  package main

  import (
  	"bytes"
  	_ "embed"
  	"image"
  	_ "image/png"

  	"github.com/hajimehoshi/ebiten/v2"
  )

  var (
  	// カボチャの画像を埋め込む
  	//go:embed pumpkin.png
  	pumpkinPng   []byte
  	pumpkinImage *ebiten.Image
+ 	theta        float64
  )

  type Game struct{}

  // Updateはゲームを1ティック更新します
  func (g *Game) Update() error {
- 	// ゲームロジックをここに書きますが、今はまだ何もしません
+ 	theta += 0.05
  	return nil
  }

  // Draw はゲーム画面を1フレーム分描画します
  func (g *Game) Draw(screen *ebiten.Image) {
  	op := &ebiten.DrawImageOptions{}
+ 	op.GeoM.Rotate(theta)
  	screen.DrawImage(pumpkinImage, op)
  }

  // Layout はゲームの論理的な画面サイズを返します
  func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
  	return outsideWidth, outsideHeight
  }
  func main() {
  	// png画像を *ebiten.Image に変換します
  	img, _, _ := image.Decode(bytes.NewReader(pumpkinPng))
  	pumpkinImage = ebiten.NewImageFromImage(img)
  	ebiten.RunGame(&Game{})
  }

それでは差分をひとつずつ確認していきましょう。

まずは角度を保持する変数 theta を用意します。

  var (
  	// カボチャの画像を埋め込む
  	//go:embed pumpkin.png
  	pumpkinPng   []byte
  	pumpkinImage *ebiten.Image
+ 	theta        float64
  )

そして Update メソッドを次のように書き換えます。
今までは何も書いていませんでしたが、ここに回転する処理を書きます。
theta の値をだんだん増えるように今回は 0.05 ずつ増加させています。

  // Updateはゲームを1ティック更新します
  func (g *Game) Update() error {
- 	// ゲームロジックをここに書きますが、今はまだ何もしません
+ 	theta += 0.05
  	return nil
  }

そして Draw メソッドの中に theta の角度を反映させるための処理を追加します。

  // Draw はゲーム画面を1フレーム分描画します
  func (g *Game) Draw(screen *ebiten.Image) {
  	op := &ebiten.DrawImageOptions{}
+ 	op.GeoM.Rotate(theta)
  	screen.DrawImage(pumpkinImage, op)
  }

ここで注意したいのは、回転する時の座標は左上の(0,0)座標を中心に回転することです。
これを実行すると次のようになります。
Windowの左上を中心に画像が回転しましたね🎉

ソースの全体像は下記からも確認できます。

https://github.com/demouth/learning-ebitengine/tree/main/003

4. 画像の中心で回転させる

先ほどはWindowの左上を中心に画像を回転させましたが、今度は画像の中心で回転させてみます。
こんなイメージで回転させたいと思います。

それではまず最初にソース全体の差分を紹介します。差分はそれほど多くはありません。

main.go
  package main

  import (
  	"bytes"
  	_ "embed"
  	"image"
  	_ "image/png"

  	"github.com/hajimehoshi/ebiten/v2"
  )

  var (
  	// カボチャの画像を埋め込む
  	//go:embed pumpkin.png
  	pumpkinPng   []byte
  	pumpkinImage *ebiten.Image
  	theta        float64
  )

  type Game struct{}

  // Updateはゲームを1ティック更新します
  func (g *Game) Update() error {
  	theta += 0.05
  	return nil
  }

  // Draw はゲーム画面を1フレーム分描画します
  func (g *Game) Draw(screen *ebiten.Image) {
  	op := &ebiten.DrawImageOptions{}
+ 	bounds := pumpkinImage.Bounds() // 画像の大きさを取得する
+ 	op.GeoM.Translate(-float64(bounds.Dx())/2, -float64(bounds.Dy())/2)
  	op.GeoM.Rotate(theta)
+ 	op.GeoM.Translate(float64(bounds.Dx())/2, float64(bounds.Dy())/2)
  	screen.DrawImage(pumpkinImage, op)
  }

  // Layout はゲームの論理的な画面サイズを返します
  func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
  	return outsideWidth, outsideHeight
  }
  func main() {
  	// png画像を *ebiten.Image に変換します
  	img, _, _ := image.Decode(bytes.NewReader(pumpkinPng))
  	pumpkinImage = ebiten.NewImageFromImage(img)
  	ebiten.RunGame(&Game{})
  }

今回の差分は Draw メソッドの中だけです。
Rotate で回転させる前に、 Translate で座標をマイナス方向に移動してから回転させます。この時の移動量は画像の真ん中に中心がくるように画像サイズの半分にしています。
そして回転が終わった後、もう一度 Translate で座標を元の位置に戻しています。

  // Draw はゲーム画面を1フレーム分描画します
  func (g *Game) Draw(screen *ebiten.Image) {
  	op := &ebiten.DrawImageOptions{}
+ 	bounds := pumpkinImage.Bounds() // 画像の大きさを取得する
+ 	op.GeoM.Translate(-float64(bounds.Dx())/2, -float64(bounds.Dy())/2)
  	op.GeoM.Rotate(theta)
+ 	op.GeoM.Translate(float64(bounds.Dx())/2, float64(bounds.Dy())/2)
  	screen.DrawImage(pumpkinImage, op)
  }

図にすると次のようなイメージです。
一番最初は画像の左上が(0,0)座標になっています。

次に画像の半分のサイズでマイナス座標に移動します。これにより画像の中心が(0,0)座標になります。

次に画像を回転させます。回転するときは(0,0)を中心に画像が回転します。

最後にマイナス座標に移動した分だけ元に戻します。
これにより画像の中心で回転しているように見えます。

このプログラムを実行すると次のようになります。
画像の中心で回転するようになりましたね!🎉

ソースの全体像は下記からも確認できます。

https://github.com/demouth/learning-ebitengine/tree/main/004

5. 画像の中心で回転させつつ移動する

先ほど画像の中心で回転させましたが、さらにカボチャの座標を移動するようにさせてみましょう。
まず最初にソース全体の差分を紹介します。

main.go
  package main

  import (
  	"bytes"
  	_ "embed"
  	"image"
  	_ "image/png"

  	"github.com/hajimehoshi/ebiten/v2"
  )

  var (
  	// カボチャの画像を埋め込む
  	//go:embed pumpkin.png
  	pumpkinPng   []byte
  	pumpkinImage *ebiten.Image
  	theta        float64
+ 	x            int
+ 	y            int
  )

  type Game struct{}

  // Updateはゲームを1ティック更新します
  func (g *Game) Update() error {
  	theta += 0.05
+ 	x += 2
+ 	y += 3
+ 	x = x % 640 // xの値が640を超えたら0に戻す
+ 	y = y % 480 // yの値が480を超えたら0に戻す
  	return nil
  }

  // Draw はゲーム画面を1フレーム分描画します
  func (g *Game) Draw(screen *ebiten.Image) {
  	op := &ebiten.DrawImageOptions{}
  	bounds := pumpkinImage.Bounds() // 画像の大きさを取得する
  	op.GeoM.Translate(-float64(bounds.Dx())/2, -float64(bounds.Dy())/2)
  	op.GeoM.Rotate(theta)
  	op.GeoM.Translate(float64(bounds.Dx())/2, float64(bounds.Dy())/2)
+ 	op.GeoM.Translate(float64(x), float64(y))
  	screen.DrawImage(pumpkinImage, op)
  }

  // Layout はゲームの論理的な画面サイズを返します
  func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
  	return outsideWidth, outsideHeight
  }
  func main() {
  	// png画像を *ebiten.Image に変換します
  	img, _, _ := image.Decode(bytes.NewReader(pumpkinPng))
  	pumpkinImage = ebiten.NewImageFromImage(img)
  	ebiten.RunGame(&Game{})
  }

それでは差分をひとつずつ確認していきましょう。

カボチャの座標を保持するための xy という変数を追加しておきます。

  var (
  	// カボチャの画像を埋め込む
  	//go:embed pumpkin.png
  	pumpkinPng   []byte
  	pumpkinImage *ebiten.Image
  	theta        float64
+ 	x            int
+ 	y            int
  )

次に Update メソッドに座標を移動する処理を書きます。
x座標を2ずつ移動して画面の右端に辿り着いたら左側に戻しています。
y座標についても3ずつ移動して画面の下端に辿り着いたら上側に戻しています。

  // Updateはゲームを1ティック更新します
  func (g *Game) Update() error {
  	theta += 0.05
+ 	x += 2
+ 	y += 3
+ 	x = x % 640 // xの値が640を超えたら0に戻す
+ 	y = y % 480 // yの値が480を超えたら0に戻す
  	return nil
  }

図にすると次のようなイメージです。左上が(0,0)で右下が(640,480)です。

そして Draw メソッドの最後に描画する座標を移動する処理を追加します。
なお行を追加する位置はとても重要で、回転させた後に移動させたいので、 DrawImage の直前に行を追加しています。

  // Draw はゲーム画面を1フレーム分描画します
  func (g *Game) Draw(screen *ebiten.Image) {
  	op := &ebiten.DrawImageOptions{}
  	bounds := pumpkinImage.Bounds() // 画像の大きさを取得する
  	op.GeoM.Translate(-float64(bounds.Dx())/2, -float64(bounds.Dy())/2)
  	op.GeoM.Rotate(theta)
  	op.GeoM.Translate(float64(bounds.Dx())/2, float64(bounds.Dy())/2)
+ 	op.GeoM.Translate(float64(x), float64(y))
  	screen.DrawImage(pumpkinImage, op)
  }

これにより回転したカボチャが移動するようになりました!🎉

ソースの全体像は下記からも確認できます。

https://github.com/demouth/learning-ebitengine/tree/main/005

おしまい

今回はEbitengine入門ということで基本的な描画処理に的を絞ってきましたが、 eihigh さんが書いている「Go言語とEbitengineによる ゼロから始めるゲームプログラミング」という本ではさらに詳しく丁寧に説明されているのでおすすめです!

https://zenn.dev/eihigh/books/ebitengine-book

Discussion