Liberent-Dev’s blog

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

ebitengineでゲームを作る3

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

今回も前回・前々回からの続きとなりますが、ebitengineを使用して作ったシューティングゲームのようなものを更に改良していきます。
前回の修正にて、ゲームとしての体裁が整ったので、もう少しクオリティを上げるための対応を下記の目次通りで対応していきます。

敵のガクガク移動の修正

前回までの状態ですと、敵の移動がスムーズでは無いため、この部分を修正していきます。
今の実装ですと、1フレームで5ドット移動し移動量多いため、ガクガクと動いているように見えてしまっています。
1ドットだけ移動するようにして、初期の移動開始と移動方向の変更も約1秒ぐらいに変更しておきます。

   if enemyDirection == 1 {
        if g.enemy[i].posx > 0.0 {
            g.enemy[i].posx -= 1
        }
    } else if enemyDirection == 2 {
        if g.enemy[i].posx < 640.0-g.enemy[i].drawWidth {
            g.enemy[i].posx += 1
        }
    }
    if enemyDirectionTime+1 < time.Now().Unix() {
        enemyDirection = rand.Intn(3)
        enemyDirectionTime = time.Now().Unix()
    }
  • 修正前

  • 修正後

自機や敵がやられた時の爆発エフェクト付ける

シューティングゲームだけでは無いですが、ゲーム全般様々なエフェクトがあり、演出面では必須なものになってきます。

今回は、自機や敵がやられた時に爆発のエフェクトを表示する処理を入れていきます。
画像に関してはオープンソースになっている、こちらを使用していきます。

ebitengineのサンプルにアニメーションの処理があるので、こちらを参考にして対応していきます。

サンプルは、アニメーション画像が綺麗に横一列になっている場合の処理になりますが、今回使用する爆発の画像は、縦方向にも存在するのでその点が注意点となります。

まずは、画像のロードと画像の表示位置変更用の定数などを設定していきます。
定数が多くなってきたので、毎回constを記載するのも面倒なので、下記のような書き方に変更します。

const (
    ENEMY_MAX           = 3
    SHOT_MAX            = 3
    EFFECT_FRAMEOX      = 4   // 追加:エフェクト画像の原点x座標
    EFFECT_FRAMEOY      = 4   // 追加:エフェクト画像の原点y座標
    EFFECT_FRAME_WIDTH  = 340 // 追加:エフェクト画像の横サイズ
    EFFECT_FRAME_HEIGHT = 340 // 追加:エフェクト画像の縦サイズ
    EFFECT_FRAME_COUNT  = 9   // 追加:エフェクト画像のフレーム数
)

エフェクト関連のデータはbaseDataの構造体内に追加します。

type baseData struct {
    posx        float64
    posy        float64
    isHit       bool
    drawImage   *ebiten.Image
    drawWidth   float64
    drawHeight  float64
    shot        [SHOT_MAX]shotData
    revivalTime time.Time
    effectImage *ebiten.Image // 追加:エフェクト用の画像
    effectCount int  // 追加:エフェクトのフレーム位置用の変数
}

初期化のロード処理内にエフェクト画像を設定する処理を追加します。

   // 抜粋
    g.my.effectImage = imageOpen("./Effect.png")
    g.my.effectCount = 0

    g.enemy[i].effectImage = imageOpen("./Effect.png")
    g.enemy[i].effectCount = 0

表示処理の自機や敵のisHittrueになった場合に、自機や敵の座標をベースにして、爆発エフェクトを表示して、必要な枚数を表示した後に非表示にします。
まず、update()内でisHittrueの場合に、エフェクトをアニメーションさせるためのカウンターを増加させていきます。

   // 自機処理の抜粋
    // ゲームオーバーなので何もしないようにする
    if g.my.isHit {
        // エフェクトのカウント
        g.my.effectCount++
        return nil
    }

    // 敵処理の抜粋
    } else {
        // エフェクトのカウント
        g.enemy[i].effectCount++
        // 時間経ってたら復活
        if g.enemy[i].revivalTime.Before(time.Now()) {
            g.enemy[i].isHit = false
            g.enemy[i].effectCount = 0 // 復活時にカウンターはリセットする
        }
    }

draw()内でisHittrueの時に、爆発画像の表示座標を自機や敵の中心部分に設定して、爆発画像の元データから数秒毎に表示する位置を変えてアニメーションをしているように見せます。
また、画像が3*3のものになっているので、1列目のものを表示し終えたらy座標を2列目に、2列目のものを表示し終えたらy座標を3列目にするという形の対応が必要になってきます。
更に列変わった場合には、x座標はリセットするという対応も同時に必要となります。

  • 敵側の処理(draw()の処理抜粋)
   for i := 0; i < ENEMY_MAX; i++ {
        if g.enemy[i].isHit {
            // 爆発表示
            if g.enemy[i].effectCount < 54 {
                op1 := &ebiten.DrawImageOptions{}
                op1.GeoM.Translate(-float64(EFFECT_FRAME_WIDTH)/2, -float64(EFFECT_FRAME_HEIGHT)/2)
                g.enemy[i].drawWidth = float64(g.enemy[i].drawImage.Bounds().Dx()) * 0.3
                g.enemy[i].drawHeight = float64(g.enemy[i].drawImage.Bounds().Dy()) * 0.3
                op1.GeoM.Translate(g.enemy[i].posx+float64(g.enemy[i].drawWidth/2), g.enemy[i].posy+float64(g.enemy[i].drawHeight/2))
                rx := (g.enemy[i].effectCount / 6) % EFFECT_FRAME_COUNT
                sx := EFFECT_FRAMEOX + (rx%3)*EFFECT_FRAME_WIDTH
                sy := EFFECT_FRAMEOY
                if g.enemy[i].effectCount >= 18 {
                    sy = EFFECT_FRAMEOX + EFFECT_FRAME_HEIGHT
                }
                if g.enemy[i].effectCount >= 36 {
                    sy = EFFECT_FRAMEOY + EFFECT_FRAME_HEIGHT*2
                }
                screen.DrawImage(g.enemy[i].effectImage.SubImage(image.Rect(sx, sy, sx+EFFECT_FRAME_WIDTH, sy+EFFECT_FRAME_HEIGHT)).(*ebiten.Image), op1)
            }
            // 敵非表示
            g.enemy[i].drawWidth = 0
            g.enemy[i].drawHeight = 0
            for j := 0; j < SHOT_MAX; j++ {
                g.enemy[i].shot[j].drawWidth = 0
                g.enemy[i].shot[j].drawHeight = 0
            }
        } else {
  • 自機側の処理(draw()内の処理抜粋)
   // 自機
    if !g.my.isHit {
        op := &ebiten.DrawImageOptions{}
        op.GeoM.Scale(0.1, 0.1)
        op.GeoM.Translate(g.my.posx, g.my.posy)
        screen.DrawImage(g.my.drawImage, op)
        g.my.drawWidth = float64(g.my.drawImage.Bounds().Dx()) * 0.1
        g.my.drawHeight = float64(g.my.drawImage.Bounds().Dy()) * 0.1
    } else {
        // 爆発表示
        if g.my.effectCount < 54 {
            op1 := &ebiten.DrawImageOptions{}
            op1.GeoM.Translate(-float64(EFFECT_FRAME_WIDTH)/2, -float64(EFFECT_FRAME_HEIGHT)/2)
            op1.GeoM.Translate(g.my.posx+float64(g.my.drawWidth/2), g.my.posy+float64(g.my.drawHeight/2))
            rx := (g.my.effectCount / 6) % EFFECT_FRAME_COUNT
            sx := EFFECT_FRAMEOX + (rx%3)*EFFECT_FRAME_WIDTH
            sy := EFFECT_FRAMEOY
            if g.my.effectCount >= 18 {
                sy = EFFECT_FRAMEOX + EFFECT_FRAME_HEIGHT
            }
            if g.my.effectCount >= 36 {
                sy = EFFECT_FRAMEOY + EFFECT_FRAME_HEIGHT*2
            }
            screen.DrawImage(g.my.effectImage.SubImage(image.Rect(sx, sy, sx+EFFECT_FRAME_WIDTH, sy+EFFECT_FRAME_HEIGHT)).(*ebiten.Image), op1)
        }
    }

  • 自機

音を鳴らす

ゲーム開発中に、よく後回しにされたり、忘れがちになる部分なのですが、音があるのと無いのとではゲームのクオリティに結構な差が出てきます。
先日とあるアクションゲームでバグって音が全く出ないけど、操作は出来る状態になったのですが面白く無いし何が起こっているか分からない状態になってしまい、音って重要だなと感じました。

では、先ほど対応した爆発エフェクトに合わせて爆発音、自機の弾の発射音を入れていきます。

ebitengineでは、mp3やwav、oggを鳴らす機能が存在しているので、そちらを使用していきます。
音素材はこちらから拝借させていただいております。

今回はmp3形式のみを扱うので単純にmp3を必要なタイミングで鳴らすだけという実装にしています。
まずは、ファイルをロードするための諸々の処理を入れていきます。

Game構造体に音用の変数を用意します。

type Game struct {
    my           baseData
    enemy        [ENEMY_MAX]baseData
    score        int
    audioContext *audio.Context  //追加
    seBytes      []byte  //追加
}

初期化処理時に音のファイルをロードします。

   // 音設定
    g.audioContext = audio.NewContext(44100)
    // ファイルのロード
    file, err := os.Open("./shot.mp3")
    if err != nil {
        panic(err)
    }
    defer file.Close()
    // MP3をゲーム内で使えるようにデコード
    src, err2 := mp3.DecodeWithoutResampling(file)
    if err2 != nil {
        panic(err2)
    }
    // デコードした内容を用意していたbyte変数へ入れておく
    var err3 error
    g.seBytes, err3 = io.ReadAll(src)
    if err3 != nil {
        panic(err3)
    }

ファイルの設定が出来たので、爆発時に音を鳴らす処理を入れていきます。
ebitengineのサンプルを見る限り、SEやジングルなど複数回鳴らす場合はaudioPlayerをその都度作成して鳴らすという感じになっていたので、今回はサンプルに合わせての実装をしています。

まずは自機の弾発射時の音を鳴らすために、update()内の自機の弾発射処理時に変更を加えます。

   // 自機の弾発射
    if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
        for i := 0; i < SHOT_MAX; i++ {
            if !g.my.shot[i].shotFlag {
                g.my.shot[i].shotFlag = true
                g.my.shot[i].shotx = g.my.posx + 13.0
                g.my.shot[i].shoty = g.my.posy + 25.0
                // 発射音(ここから追加)
                sePlayer := g.audioContext.NewPlayerFromBytes(g.seBytes)
                sePlayer.Play()
                break
            }
        }
    }

同じような形で爆発時の処理に音を鳴らす形で実装すれば、爆発時にも音が鳴るようになります。
爆発時の音処理に関しては、後述するgitに上がっているソースを参照してください。

スコアを画像化

こちらもよくある処理になりますが、爆発と同じような形で数字画像を用意しておき、スコアに合わせて表示をする方法で対応していきます。

記事作成時のebitengineの最新バージョンである2.6.3では未対応ですが、2.6.4以降のebitengineにはfont周りに色々修正が入りそうで、フォントファイル(ttfファイル)をソース内でロードしたら、そのまま使用できるような形になっているようです。
ただし、正式リリースにはまだ反映されていないようなので、今回はそちらの使用は見送ります。

fontを使う上での注意点ですが、ライセンスを確認必要があります。
今回はライセンスフリーM PLUSのフォントを使用して画像を用意しました。

画像の変数と定数の定義とInit()内での画像のロード処理となります。

const (
    ENEMY_MAX           = 3
    SHOT_MAX            = 3
    EFFECT_FRAMEOX      = 4
    EFFECT_FRAMEOY      = 4
    EFFECT_FRAME_WIDTH  = 340
    EFFECT_FRAME_HEIGHT = 340
    EFFECT_FRAME_COUNT  = 9
    SCORE_OX            = 6 // 追加
    SCORE_OY            = 0 // 追加
    SCORE_WIDTH         = 32 // 追加
    SCORE_HEIGHT        = 64 // 追加
)

type Game struct {
    my               baseData
    enemy            [ENEMY_MAX]baseData
    score            int
    audioContext     *audio.Context
    shotSEBytes      []byte
    explosionSEBytes []byte
    scoreImage       *ebiten.Image  //追加
}

func (g *Game) Init() {
    // 初期処理
    //〜省略〜
    // 得点用の画像ロード
    g.scoreImage = imageOpen("./score.png")
}

Draw()内にてスコアに合わせて、画像の位置を変えて画面側に表示する対応をしていきます。

func (g *Game) Draw(screen *ebiten.Image) {
    // 自機の弾
    // 〜省略〜
    // スコア表示(桁数分ループしてベース画像から数字を引っ張ってくる)
    for i := 0; i <= 6; i++ {
        // 数字の桁部分から値を取得する
        s := getDigits(g.score, i, i)

        // スコアの数字から取得位置変える
        // 桁数から表示位置を変える
        op4 := &ebiten.DrawImageOptions{}
        op4.GeoM.Scale(0.5, 0.5)
        op4.GeoM.Translate(float64(600-(i*20)), 0)
        sx := SCORE_OX + (s * SCORE_WIDTH)
        sy := SCORE_OY

        screen.DrawImage(g.scoreImage.SubImage(image.Rect(sx, sy, sx+SCORE_WIDTH, sy+SCORE_HEIGHT)).(*ebiten.Image), op4)
    }
}

新規に用意したメソッドgetDigits()は数字の指定した桁の値を取得する処理となります。

func getDigits(value int, start int, end int) int {
    var mod_value int
    var result int

    // n桁目以下の桁を取得
    mod_value = value % int(math.Pow(10, float64(end)+1))

    // m桁目以上の桁を取得
    result = mod_value / int(math.Pow(10, float64(start)))

    return result
}
  • 得点動画

おまけ

今回対応分のソースはこちらにありますので、参考にしてください。

まとめ

更にタイトル画面やゲームオーバ後のリトライ機能のアウトゲーム部分やスコアアタックが出来るような仕組みを組み込んでいけば、ゲームとして完成に近付いていくかと思いますが、ebitengineの記事は一旦今回で終了予定となります。


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

https://liberent.co.jp/recruit/