Liberent-Dev’s blog

株式会社リベル・エンタテインメントのテックブログです。

ebitengineでゲームを作る

こんにちは!
システム開発部のK.Mです。

今回は前回からの続きで、ebitengineを使って簡単なシューティングゲームのようなものを作っていきます。

用意するもの

シューティングゲームなので自機・敵・弾などの各種画像が必要となります。
今回はいらすとやさんから調達させていただきました。

  • 自機の画像

  • 自機の弾の画像

  • 敵の画像

  • 敵の弾の画像

自機の表示と操作

画像ファイルのロード

ebitengineで画像表示するために用意されているAPIは、go標準の画像データを渡す形になっています。
そのため、goの処理にてファイルオープンを行いデコードした情報を渡す必要があります。

今回はpngファイルを使用しているため、下記の例はpngの画像ファイルをオープンする場合となります。

var drawImage *ebiten.Image
// ファイルオープン
file, _ := os.Open("pngファイルのパス")
defer file.Close()
// デコード
img, err := png.Decode(file)
if err != nil {
    panic(err)    
}
drawImage = ebiten.NewImmageFromImage(img)

画像ファイルの表示

Draw()内にて、画像データをそのまま表示すると大きいため、Scaleにて大きさを調整して表示させる必要があります。

op := &ebiten.DrawImageOption{}
op.GeoM.Scale(0.1, 0.1)
screen.DrawImage(drawImage, op)

自機の操作

キー操作によって画像を移動させるための位置情報を持つ変数を用意します。
(自機のx座標とy座標用)

var posx float64 = 0.0
var posy float64 = 0.0

Update()内にて、キー操作によってx座標とy座標を増減させる処理を入れます。

if ebiten.IsKeyPressed(ebiten.KeyUp) {
    // 上キー
    posy -= 0.5
}
if ebiten.IsKeyPressed(ebiten.KeyDown) {
    // 下キー
    posy += 0.5
}
if ebiten.IsKeyPressed(ebiten.KeyRight) {
    // 右キー
    posx += 0.5
}
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
    // 左キー
    posx -= 0.5
}

Draw()内にて自機の画像に対して、xとy座標を指定するようにしておきます。

op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(0.1, 0.1)
op.GeoM.Translate(posx, posy) // これを追加
screen.DrawImage(drawImage, op)

結果

自機の画像が表示されて、キーボードの上下左右の矢印キー操作によって自機を動かせるようになります。

自機の弾発射

画像ファイルのロード

自機の画像と同じように、弾用の画像をロードします。

var shotImage *ebiten.Image
// ファイルを開く
file2, _ := os.Open("pngファイルのパス")
defer file2.Close()
// デコード
img2, err2 := png.Decode(file2)
if err2 != nil {
    panic(err2)
}
shotImage = ebiten.NewImageFromImage(img2)

画像ファイルの表示

Draw()内にて、自機と同じように画像データが大きいため、Scaleにて表示時の大きさを調整して表示させます。

op2 := &ebiten.DrawImageOptions{}
op2.GeoM.Scale(0.1, 0.1)
op2.GeoM.Translate(shotx, shoty)  // この後の弾の位置用
screen.DrawImage(shotImage, op2)

弾の発射操作

弾の位置情報用と表示用の変数を用意します。

var shotFlag bool = false
var shotx float64 = 0.0
var shoty float64 = 0.0

Update()内にて、キー入力で弾の画像表示フラグをONにして、自動移動させます。

// 弾発射(一発のみ)
if ebiten.IsKeyPressed(ebiten.KeySpace) && !shotFlag {
    shotFlag = true
    shotx = posx + 13.0 // 自機の現在位置から画像のサイズを合わせて数字を調整
    shoty = posy + 25.0
}
if shotFlag {
    shoty += 1.0  // 自動移動用
}

画面端を超えたら、無駄な処理になるため表示しないように表示フラグをOFFにします。

if shotx > 640 || shoty > 480 {
    shotFlag = false
}

弾の表示

Draw()内にて、表示フラグがONの場合のみ弾の画像を表示するようにします。

// if文を追加
if shotFlag {
    op2 := &ebiten.DrawImageOptions{}
    op2.GeoM.Scale(0.1, 0.1)
    op2.GeoM.Translate(shotx, shoty)
    screen.DrawImage(shotImage, op2)
}

結果

スペースキーを押せば、弾が発射されるようになりました。
(今回の対応では、弾は1発しか出ません。)

敵の表示と動き + 敵の弾の表示

画像ファイルのロード

敵の画像と敵の弾画像も他の画像同様の処理でロードします。

var enemyImage *ebiten.Image
var enemyshotImage *ebiten.Image
var enemyDirectionTime int64

// ファイルオープン
file3, _ := os.Open("pngファイルのパス")
defer file3.Close()
// デコード
img3, err3 := png.Decode(file3)
if err3 != nil {
    panic(err3)
}
enemyImage = ebiten.NewImageFromImage(img3)

// ファイルオープン
file4, _ := os.Open("pngファイルのパス")
defer file4.Close()
// デコード
img4, err4 := png.Decode(file4)
if err4 != nil {
    panic(err4)
}
enemyshotImage = ebiten.NewImageFromImage(img4)

enemyDirectionTime = time.Now().Unix()  // これは敵の動き出しを少し待つために使う

敵の動きと敵の弾発射

敵の位置情報と敵の弾の位置情報など必要な諸々の変数定義しておきます。

var enemyposx float64 = 250.0
var enemyposy float64 = 320.0
var enemyDirection int = 0 // 敵の移動方向
var enemyshotFlag bool = false
var enemyshotx float64 = 0.0
var enemyshoty float64 = 0.0

Update()内にて、ランダムで一定方向に移動して、自機より移動速度が早い弾を撃つようにします。

// 敵の移動(数秒同じ方向、左右ランダム)
if enemyDirection == 1 {
    enemyposx -= 5
} else if enemyDirection == 2 {
    enemyposx += 5
}
// 起動してから3秒経過したら移動開始する
if enemyDirectionTime+3 < time.Now().Unix() {
    rand.Seed(time.Now().UnixNano())
    enemyDirection = rand.Intn(3)
}
// 敵の弾の処理
if !enemyshotFlag {
    enemyshotFlag = true
    enemyshotx = enemyposx + 60
    enemyshoty = enemyposy
} else {
    enemyshoty -= 8.0
    // 範囲外に行ったら非表示
    if enemyshotx < 0 || enemyshoty < 0 {
        enemyshotFlag = false
    }
}

敵と敵の弾の表示

Draw()内にて、敵と敵の弾の表示処理を追加します。

// 敵の弾
if enemyshotFlag {
    op4 := &ebiten.DrawImageOptions{}
    op4.GeoM.Scale(0.1, 0.1)
    op4.GeoM.Translate(enemyshotx, enemyshoty)
    screen.DrawImage(enemyshotImage, op4)
}
// 敵
op3 := &ebiten.DrawImageOptions{}
op3.GeoM.Scale(0.3, 0.3)
op3.GeoM.Translate(enemyposx, enemyposy)
screen.DrawImage(enemyImage, op3)

結果

少し動きがぎこちないですが、敵が動いて弾も発射するようになりました。

当たり判定

自機、自機の弾、敵、敵の弾の当たり判定

ebitengin自身には画像同士の当たり判定を判断するAPIは存在しないため、当たり判定処理を実装する必要があります。

画像で表すと、上記のように赤い弾に対しては敵と当たっていると判断が必要で、紫の弾に関しては敵と当たっていないものとなります。
また、今回は回転しない単純な矩形同士なので複雑な判定は不要になってきます。
回転している矩形や円などの当たり判定は少し面倒になってきますが、この辺りは検索して確認してみてください。

// 必要な変数
var isEnemyHit bool = false  // 敵に弾が当たった判定用
var isMyHit bool = false     // 自機に敵の弾か敵が当たった判定用
var myWidth float64          // 自機の表示横サイズ
var myHeight float64         // 自機の表示縦サイズ
var shotWidth float64        // 弾の表示横サイズ
var shotHeight float64       //  弾の表示縦サイズ
var enemyWidth float64       // 敵の表示横サイズ
var enemyHeight float64      // 敵の表示縦サイズ
var enemyShotWidth float64   // 敵の弾の表示横サイズ
var enemyShotHeight float64  // 敵の弾の表示縦サイズ

Draw()内の画像表示時に判定用の表示画像の縦横サイズを設定します

// 元画像のサイズにスケールで変更した分を反映して画面上の縦横サイズで判断する
// 自機の弾
if shotFlag {
    op2 := &ebiten.DrawImageOptions{}
    op2.GeoM.Scale(0.1, 0.1)
    op2.GeoM.Translate(shotx, shoty)
    screen.DrawImage(shotImage, op2)
    shotWidth = float64(shotImage.Bounds().Dx()) * 0.1
    shotHeight = float64(shotImage.Bounds().Dy()) * 0.1
}
// 敵の弾
if enemyshotFlag {
    op4 := &ebiten.DrawImageOptions{}
    op4.GeoM.Scale(0.1, 0.1)
    op4.GeoM.Translate(enemyshotx, enemyshoty)
    screen.DrawImage(enemyshotImage, op4)
    enemyShotWidth = float64(enemyshotImage.Bounds().Dx()) * 0.1
    enemyShotHeight = float64(enemyshotImage.Bounds().Dy()) * 0.1
}
// 敵
op3 := &ebiten.DrawImageOptions{}
op3.GeoM.Scale(0.3, 0.3)
op3.GeoM.Translate(enemyposx, enemyposy)
screen.DrawImage(enemyImage, op3)
enemyWidth = float64(enemyImage.Bounds().Dx()) * 0.3
enemyHeight = float64(enemyImage.Bounds().Dy()) * 0.3
// 自機
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(0.1, 0.1)
op.GeoM.Translate(posx, posy)
screen.DrawImage(drawImage, op)
myWidth = float64(drawImage.Bounds().Dx()) * 0.1
myHeight = float64(drawImage.Bounds().Dy()) * 0.1

矩形用の当たり判定処理をメソッドとして用意します。

// ヒット判定用の関数(trueがヒット、falseが未ヒット)
func isHit(posx1 float64, posy1 float64, width1 float64, height1 float64, posx2 float64, posy2 float64, width2 float64, height2 float64) bool {
    // x座標に関する判定
    if posx2+width2 < posx1 {
        return false
    }
    if posx1+width1 < posx2 {
        return false
    }
    // y座標に関する判定
    if posy2+height2 < posy1 {
        return false
    }
    if posy1+height1 < posy2 {
        return false
    }
    return true
}

Update()内にて、上記で用意した当たり判定用の処理を呼び出します。

// 当たり判定
// 自機の弾と敵
if isHit(shotx, shoty, shotWidth, shotHeight, enemyposx, enemyposy, enemyWidth, enemyHeight) {
    // 当たっているので敵を非表示
    isEnemyHit = true
}
// 自機と敵 か 自機と敵の弾
if isHit(posx, posy, myWidth, myHeight, enemyposx, enemyposy, enemyWidth, enemyHeight) ||
    isHit(posx, posy, myWidth, myHeight, enemyshotx, enemyshoty, enemyShotWidth, enemyShotHeight) {
    // 当たっているので自機消失(ゲームオーバー)
    isMyHit = true
}

自機が敵の弾及び敵と当たったらゲームオーバー処理

当たり判定処理にて、自機と敵の弾か敵と当たったらゲームオーバー処理を行います。
(isMyHitがtrueになっていた場合はゲームオーバーの処理の追加)

Draw()内にて、isMyHitがtrueならゲームオーバーの文字を表示するようにします。
今回は一時的なものなのでDebugPrintにて表示しています。

if isMyHit {
    ebitenutil.DebugPrint(screen, "GameOver!!!")
}

Update()の最初にisMyHitがtrueなら、それ以降の処理は行わないようにします。

func (g *Game) Update() error {
    // ゲームオーバーなので何もしないようにする
    if isMyHit {
        return nil
    }
〜略〜
}

結果

自機の弾と敵が当たった場合に敵が消える処理

当たり判定処理で敵に自機の弾が当たった場合に、敵を非表示にさせます。
(isEnemyHitがtrueになっていた場合は敵と敵の弾を非表示にする処理の追加)

Update()内にて、isEnemyHitがfalseの場合に各種敵処理を行うように修正します。

if !isEnemyHit {
    // 敵の移動(数秒同じ方向、左右ランダム)
    if enemyDirection == 1 {
        enemyposx -= 5
    } else if enemyDirection == 2 {
        enemyposx += 5
    }
    if enemyDirectionTime+3 < time.Now().Unix() {
        rand.Seed(time.Now().UnixNano())
        enemyDirection = rand.Intn(3)
    }

    // 敵の弾発射
    if !enemyshotFlag {
        enemyshotFlag = true
        enemyshotx = enemyposx + 60
        enemyshoty = enemyposy
    } else {
        enemyshoty -= 8.0
        // 範囲外に行ったら非表示
        if enemyshotx < 0 || enemyshoty < 0 {
            enemyshotFlag = false
        }
    }
}

Draw()内で、isEnemyHitがfalseの場合に各種敵処理を行うように修正します。
trueの場合は当たり判定用の縦横サイズを0にしておきます。

if isEnemyHit {
    // 敵非表示
    ebitenutil.DebugPrint(screen, "enemyHit!!!")
    enemyWidth = 0
    enemyHeight = 0
    enemyShotWidth = 0
    enemyShotHeight = 0
} else {
    // 敵の弾
    if enemyshotFlag {
        op4 := &ebiten.DrawImageOptions{}
        op4.GeoM.Scale(0.1, 0.1)
        op4.GeoM.Translate(enemyshotx, enemyshoty)
        screen.DrawImage(enemyshotImage, op4)
        enemyShotWidth = float64(enemyshotImage.Bounds().Dx()) * 0.1
        enemyShotHeight = float64(enemyshotImage.Bounds().Dy()) * 0.1
    }
    // 敵
    op3 := &ebiten.DrawImageOptions{}
    op3.GeoM.Scale(0.3, 0.3)
    op3.GeoM.Translate(enemyposx, enemyposy)
    screen.DrawImage(enemyImage, op3)
    enemyWidth = float64(enemyImage.Bounds().Dx()) * 0.3
    enemyHeight = float64(enemyImage.Bounds().Dy()) * 0.3
}

結果

冗長な処理の修正

一応、これまでの対応で簡単なシューティングゲームのようなものは出来ましたが、色々と改善していく必要がある箇所があります。
その中で、今回は画像のロード処理を修正していきます。

画像のロード処理にて同じ処理が4つも並んでおり、今後更に画像を追加する必要があった場合に面倒なのと、そもそも見た目も良くないので修正していきましょう。

同じ処理が並んでいる場合は、共通化出来る部分はメソッドにて処理を1箇所にまとめてしまいます。
まずは画像をロードする処理だけを下記のように用意します。

func imageOpen(name string) *ebiten.Image {
    // ファイルオープン
    file, _ := os.Open(name)
    defer file.Close()
    // デコード
    img, err := png.Decode(file)
    if err != nil {
        panic(err)
    }
    return ebiten.NewImageFromImage(img)
}

このようなメソッドを用意すると画像のロード処理が下記のように短くなります。

// ファイルオープン
file, _ := os.Open("pngファイルのパス")
defer file.Close()
// デコード
img, err := png.Decode(file)
if err != nil {
    panic(err)    
}
drawImage = ebiten.NewImmageFromImage(img)
// ファイルオープン
file2, _ := os.Open("pngファイルのパス")
defer file2.Close()
// デコード
img2, err2 := png.Decode(file2)
if err2 != nil {
    panic(err2)
}
shotImage = ebiten.NewImageFromImage(img2)
// ファイルオープン
file3, _ := os.Open("pngファイルのパス")
defer file3.Close()
// デコード
img3, err3 := png.Decode(file3)
if err3 != nil {
    panic(err3)
}
enemyImage = ebiten.NewImageFromImage(img3)
// ファイルオープン
file4, _ := os.Open("pngファイルのパス")
defer file4.Close()
// デコード
img4, err4 := png.Decode(file4)
if err4 != nil {
    panic(err4)
}
enemyshotImage = ebiten.NewImageFromImage(img4)

// 自機画像
drawImage = imageOpen("./quadcopter_drone.png")
// 自機の弾画像
shotImage = imageOpen("./ball11_gold.png")
// 敵画像
enemyImage = imageOpen("./animal_hebi_cobra.png")
// 敵の弾画像
enemyshotImage = imageOpen("./ball01_red.png")

サンプルコード

今回の実際に動かしたコードをgithubにアップロードしておきましたので、参考にしてください。

まとめ

ある程度自分で動かせるものは作れましたが、画像ロードの処理をまとめる件を含めてもう少し実装方法を洗練させたり、機能を追加する余地がありますので、次回以降で様々な箇所を修正した内容をやっていきます。


リベル・エンタテインメントでは、このような最新技術などの取り組みに興味のある方を募集しています。
もしご興味を持たれましたら下記サイトにアクセスしてみてください。 https://liberent.co.jp/recruit/