🍉

スイカゲームの作り方

2024/01/26に公開1

はじめに

Go言語用のEbitengineというゲームエンジンに興味があったので、試しにスイカゲームもどきを作りってみました。Ebitengineを使うと意外と簡単にスイカゲームもどきを作る事ができたので、なるべく分かりやすく作り方を紹介してみようと思います。

Ebitengine

まずは先に完成品をお見せします。ちなみにフルーツの回転は面倒くさいので実装しないことにしました。

ちなみにEbitengineはwasmでも普通に動きます。ビルドしたものをこちらに置いたのでブラウザから実際に動かす事ができます。
https://demouth.github.io/suika-game-go/

それではスイカゲームもどきを作って行きましょう。

描画する

まず一番初めにフルーツの画像を作るところから始めます。これはリンゴ、オレンジ、ブドウ、パイナップル、メロン、スイカのつもりです。

続いてフルーツを定義します

type Fruit struct {
    X      float64
    Y      float64
    Radius float64
}

このフルーツのXY座標を中心にRadiusの2倍の大きさで描画していくことになります。図にするとこのようなイメージです。

ちなみに座標は画面左上が原点で、右下に行くほどプラス座標になり、左上がマイナス座標となります。

フルーツは沢山表示する必要があるので、リストで定義しつつ、仮の座標をベタがきしておきます。

fruits = []*Fruit{
    {X: 100, Y: 100, Radius: 25},
    {X: 250, Y: 100, Radius: 50},
}

ここまでできたら描画関数を書きます。

フルーツのリストを描画関数に渡すことで全てのフルーツを描画するように実装します。
画像の描画方法は完全に理解する必要はないので、なんとなくおまじない的なものと思って良いです。

func (d *Draw) Fruits(screen *ebiten.Image, fruits []*Fruit) {
    l := len(fruits)
    for i := 0; i < l; i++ {
        f := fruits[i]
        d.Fruit(screen, f)
    }
}
func (d *Draw) Fruit(screen *ebiten.Image, f *Fruit) {
    var img *ebiten.Image
    img = appleImage // ここに事前にリンゴの画像を書き込んでおく

    w, h := img.Bounds().Dx(), img.Bounds().Dy()
    d.op.Filter = ebiten.FilterLinear
    d.op.GeoM.Reset()
    d.op.GeoM.Translate(-float64(w)/2, -float64(h)/2)
    d.op.GeoM.Scale(f.Radius/float64(w)*2, f.Radius/float64(h)*2)
    d.op.GeoM.Translate(float64(f.X), float64(f.Y))
    screen.DrawImage(img, &d.op)
}

ここまで来ると次のように画面に表示されるようになります。

いい感じです。

全体のソースはこちらから確認できます。

フルーツを落下させる

スイカゲームはフルーツを落下させるのが基本の動きとなるので、次はフルーツを下に移動させる関数を作ろうと思います。

落下させるというと難しそうな感じがしますが、実はとても簡単で fruite.Y += 1 とすればY方向(下方向)に移動していきます。
具体的にはこうです。このように書くと全てのフルーツが下に向かって等速で動きます。

func (u *Calc) move(fruits []*Fruit) {
	l := len(fruits)
	for i := 0; i < l; i++ {
		f := fruits[i]
		f.Y += 1
	}
}

とても順調です。

ソースはこちら。

自然に落下させる

ここまででフルーツが下に移動するようななりましたが、等速で落下しているため違和感のある挙動に感じます。
そこでもう少し重力っぽい挙動になるように書き換えます。

まずは加速度のプロパティを追加します

  type Fruit struct {
  	X      float64
  	Y      float64
+	VX     float64
+	VY     float64
   	Radius float64
  }

そして直接Y座標を書き換えるのではなく、加速度分だけ移動するようにします。

+ const gravity = 0.4
  
   func (u *Calc) move(fruits []*Fruit) {
   	l := len(fruits)
   	for i := 0; i < l; i++ {
   		f := fruits[i]
-		f.Y += 1
+		f.VY += gravity
+		f.X += f.VX
+		f.Y += f.VY
   	}
  }

たったこれだけの変更で時間が経過するにつれ下方向に加速して落下するようになりました。

それっぽくなってきましたね。

ソースはこちら。

画面の外にはみ出ないようにする

スイカゲームではフルーツは画面の外にはみ出しません、そこでフルーツがはみ出たかを判定して、もしもはみ出ていたらフルーツを跳ね返すようにしようと思います。

跳ね返すのは意外と簡単で、 fruit.VY *= -1 のように加速度に-1をかけて反転させるだけです。ただし現実世界では綺麗にエネルギーが反転するわけではないので、実装するときには -1 ではなく、 -0.5 とか、それくらいにすると良いと思います。

具体的にはこんな関数を定義しました。

const bounce = 1.0

func (u *Calc) screenWrap(fruits []*Fruit) {
    l := len(fruits)
    for i := 0; i < l; i++ {
        f := fruits[i]
        if f.X-f.Radius < 0 { // 左側にはみ出した
            f.X = f.Radius
            f.VX *= -bounce
        } else if screenWidth < f.X+f.Radius { // 右側にはみ出した
            f.X = screenWidth - f.Radius
            f.VX *= -bounce
        }
        if f.Y < 0 { // 上側にはみ出した
            // 何もしない
        } else if screenHeight < f.Y+f.Radius { // 下側にはみ出した
            f.Y = screenHeight - f.Radius
            f.VY *= -bounce
        }
    }
}

その上で横方向の加速度VXを変更して、横に進ように変更します。

 	fruits = []*Fruit{
-		{X: 100, Y: 100, VX: 0, VY: 0, Radius: 25},
-		{X: 250, Y: 100, VX: 0, VY: 0, Radius: 50},
+		{X: 100, Y: 100, VX: -15, VY: 0, Radius: 25},
+		{X: 250, Y: 100, VX: 15, VY: 0, Radius: 50},
 	}

すると、先ほど定義したscreenWrap関数を呼び出すとはみ出さずにフルーツが反射するようになりました。

しかしこの変更によりフルーツが永遠に反射し続けて止まらなくなってしまいました。
そこで下記の変更を加えて段々動きが止まるようにしました。

 const (
-	gravity = 0.4
-	bounce  = 1.0
+	gravity  = 0.98
+	friction = 0.98
+	bounce   = 0.3
 )

 func (u *Calc) move(fruits []*Fruit) {
 	l := len(fruits)
 	for i := 0; i < l; i++ {
 		f := fruits[i]
+		f.VX *= friction
+		f.VY *= friction
 		f.VY += gravity
 		f.X += f.VX
 		f.Y += f.VY
 	}
 }

これで次のように段々と止まるようになりました。

順調です。

ソースは こちらこちらこちら

フルーツ同士が当たったら跳ね返す

ここまででフルーツが画面外部に飛び出していかなくなりましたが、フルーツ同士がぶつかっても何も起きずにすり抜けてしまっています。
そこで、全てのフルーツ同士の座標を比較して、衝突していたらお互いを反対側に弾き返す処理を組み込みます。

衝突しているかどうかは、お互いのフルーツ半径(Radius)を足したものよりも距離が短ければ衝突しているとみなすことにします。

距離はピタゴラスの定理で求められます。

プログラムで書くとこんな感じで衝突の判定を行います。

dx := a.X - b.X
dy := a.Y - b.Y
d := math.Sqrt(dx*dx + dy*dy)
if d < f.Radius + g.Radius {
    // 衝突した
}

次に、衝突していたらお互いのフルーツを反対方向に遠ざけるように加速度を加算します。
そのためには、どの方向にフルーツを弾き飛ばすのか決めるため、まずはお互いの角度を求める必要がありますが、これも簡単で関数1つ呼び出すだけで角度が求まります。

角度がわかったら、あとはcos、tanでx,y座標が求まります。

最後にこれを関数に書き起こすとこんな感じになりました。

func (u *Calc) hitTest(fruits []*Fruit) {
	l := len(fruits)
	for i := 0; i < l; i++ {
		for j := i + 1; j < l; j++ {
			f := fruits[i]
			g := fruits[j]
			dx := g.X - f.X
			dy := g.Y - f.Y
			d := math.Sqrt(dx*dx + dy*dy)
			minD := f.Radius + g.Radius
			if d < minD {
				// collision
				angle := math.Atan2(dy, dx)
				tx := f.X + math.Cos(angle)*minD
				ty := f.Y + math.Sin(angle)*minD
				ax := (tx - g.X) * spring
				ay := (ty - g.Y) * spring
				f.VX -= ax
				f.VY -= ay
				g.VX += ax
				g.VY += ay
			}
		}
	}
}

フルーツの量を増やして動かしてみるとこんな感じになりました。

もう一息です。

ソースはこちら。

色んなフルーツを実装する

ここまでは全てリンゴを使って実装してきましたが、この辺で色々なフルーツを表示できるようにしようと思います。

+ const (
+ 	APPLE = iota
+ 	GRAPE
+ 	ORANGE
+ 	PINEAPPLE
+ 	MELON
+ 	WATERMELON
+ )
  type Fruit struct {
  	X      float64
  	Y      float64
  	VX     float64
  	VY     float64
  	Radius float64
+	Type   int
  }

フルーツを作る場合はこのようなコンストラクタ関数を使うようにしてみました。

func NewApple(x float64, y float64) *Fruit {
	return &Fruit{
		X:      x,
		Y:      y,
		Radius: 20,
		Type:   APPLE,
	}
}
func NewOrange(x float64, y float64) *Fruit {
	return &Fruit{
		X:      x,
		Y:      y,
		Radius: 35,
		Type:   ORANGE,
	}
}

そして描画処理を書き換え、Typeによって描画に使用する画像を差し替えるようにします。
これにより色々なフルーツが描画されるようになりました。

だいぶそれっぽくなってきました。

ソースはこちら。

同じフルーツが当たったら次のフルーツに変える

ここまででフルーツ同士の衝突判定が実装できたので、あとは衝突したフルーツが同じ種類だったら、両方のフルーツを消して新しいフルーツを追加する実装を行います。
少し難しい感じがしますが実はとても簡単です。フルーツを消すのは単純にフルーツの配列から消すだけですし、フルーツを追加するのは配列に追加するだけです。
追加するフルーツは衝突したフルーツの中間地点の座標に配置するようにします。

これを実装するとこんな感じになりました。

完成が見えてきました。

ソースはこちら。

ゲームの体裁を整える

ここまででほぼゲームの基本部分は実装できました。
このあとは キーボード操作 により 新しいフルーツ を落としたり、 スコア表示 を実装していきますが、これについてはそれほど難しい訳ではないので説明は不要だと思います。
もしも詳しく知りたい場合はgithubからcommit historyを確認してみてください。
https://github.com/demouth/suika-game-go

それと、ここまで作ったものをwasm用にビルドする事も簡単で、特にソースの書き換えをすることなくブラウザで動かす事ができます。ビルド方法も次のURLにある通りにするだけでとても簡単です。
https://ebitengine.org/ja/documents/webassembly.html

以上です。

Discussion