Liberent-Dev’s blog

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

ebitengineでゲームを作る2

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

今回も前回からの続きとなりますが、ebitengineを使用して作ったシューティングゲームのようなものを改良していきます。
前回作ったものから、下記の内容で修正を行なっていきます。

自機や敵、弾を構造体で管理する

前回の実装では、自機や敵などの位置情報や表示非表示の各種情報をそれぞれの変数で保持していますが、自機と敵で同じ内容の情報(位置情報や表示非表示)を定義している状態ですが、全てを一つに纏めてベースの構造体を用意して、自機や敵で使うように修正します。

まずは、自機や敵の情報を保持する構造体を定義します。

type baseData struct {
    posx       float64
    posy       float64
    isHit      bool
    drawImage  *ebiten.Image
    drawWidth  float64
    drawHeight float64
    shot       shotData
}

次に弾情報を保持する構造体を定義します。

type shotData struct {
    shotx      float64
    shoty      float64
    shotFlag   bool
    drawImage  *ebiten.Image
    drawWidth  float64
    drawHeight float64
}

自機と敵の情報は、ゲーム中全ての場所で使用する必要があるので、ebitengineのGame構造体に定義するようにします。

type Game struct {
    my    baseData // 自機の情報
    enemy baseData // 敵の情報
}

構造体を用意出来たので、ゲーム起動時の1回だけ初期化処理を行い各種初期データの設定を行います。
そのための初期化処理の対応を入れていきます。

当初、ゲーム起動の処理を下記のようにしていました。

ebiten.SetWindowSize(640, 480)
ebiten.SetWindowTitle("Hello, World!")

if err := ebiten.RunGame(&Game{}); err != nil {
    log.Fatal(err)
}

RunGame前にGame構造体に設定した各種情報保持用の構造体に対して初期化処理を行いたいので、まずは下記のように変更します。

ebiten.SetWindowSize(640, 480)
ebiten.SetWindowTitle("Hello, World!")

game := NewGame()

if err := ebiten.RunGame(game); err != nil {
    log.Fatal(err)
}

そして、NewGame()メソッドを新たに作成します。

func NewGame() *Game {
    // Create the game
    game := &Game{}
    game.Init()
    return game
}

NewGame()内で各種構造体の変数を初期化処理するのではなく、Game構造体に初期化用のメソッドを用意して、その初期化用のメソッド(Init())をNewGame()内で呼び出すようにしています。
肝心のInit()メソッドは下記のように自機と自機の弾、敵と敵の弾を初期化する処理となります。

func (g *Game) Init() {
    // 初期処理
    g.my.posx = 0.0
    g.my.posy = 0.0
    g.my.isHit = false
    // 画像ロード
    g.my.drawImage = imageOpen("./quadcopter_drone.png")
    g.my.shot.drawImage = imageOpen("./ball11_gold.png")
    g.my.shot.shotFlag = false

    g.enemy.posx = 250.0
    g.enemy.posy = 320.0
    g.enemy.isHit = false
    // 画像ロード
    g.enemy.drawImage = imageOpen("./animal_hebi_cobra.png")
    g.enemy.shot.drawImage = imageOpen("./ball01_red.png")
    g.enemy.shot.shotFlag = false
}

上記の対応をしたうえで、前回使用していたつposxやposyなどの通常の変数を削除し、エラーになっている箇所を適切な構造体のメンバに直していきます。

一例ですが、posxg.my.posxという形に修正していく必要があります。
エラーが無くなるまで修正を終えて、実行して前回同様の動きになっていればOKです。

敵を複数体表示させる

前回の実装では、敵が1体しか表示されない状態です。
そのため複数体表示出来るように修正していきます。
まずは、Game構造体の敵情報を配列化します。

type Game struct {
    my    baseData
    enemy [3]baseData
}

表示する数の配列を用意します。今回は3体表示させるので、3を指定しています。
この実装でも動きますが、今後敵を増やしたいとなった場合に3などの数字を指定している箇所を全て修正する必要があるため、定数を用意します。

cont ENEMY_MAX = 3

そして、配列も下記のように定義しておきます。

type Game struct {
    my    baseData
    enemy [ENEMY_MAX]baseData
}

定義が終わったら敵の各種処理(Draw()やUpdate())を行っている場所を、forループして敵の数だけ動作するようにします。
一例として、Init()内の初期化処理です。

for i := 0; i < ENEMY_MAX; i++ {
    g.enemy[i].posx = 150.0 + float64(100.0*i)
    g.enemy[i].posy = 320.0
    g.enemy[i].isHit = false
    // 画像ロード
    g.enemy[i].drawImage = imageOpen("./animal_hebi_cobra.png")
    g.enemy[i].shot.drawImage = imageOpen("./ball01_red.png")
    g.enemy[i].shot.shotFlag = false
}

他の当たり判定や敵の表示処理も同様にforループする処理を追加しておきます。

自機及び敵にて弾を連射できるようにする

前回の実装では、自機・敵ともに弾は1発発射すると、画面外に消えるまでは次の弾が発射出来ないようになっています。
今回は何発かは連射して出せるように修正していきます。

まずは、弾情報を配列化していきますが、発射可能な弾数を定義しておきます。
今回は3発とします。

const SHOT_MAX = 3

type baseData struct {
    posx       float64
    posy       float64
    isHit      bool
    drawImage  *ebiten.Image
    drawWidth  float64
    drawHeight float64
    shot       [SHOT_MAX]shotData
}

敵の配列化と同じように、弾の処理を行っている箇所をforでループして弾の数だけ動作するようにします。
一例として、Init()の初期処理内の画像ロード部分となります。

for i := 0; i < SHOT_MAX; i++ {
    g.my.shot[i].drawImage = imageOpen("./ball11_gold.png")
    g.my.shot[i].shotFlag = false
}

また、自機の弾の発射箇所にて、IsKeyPressed()からIsKeyJustPressed()に変更して、少し工夫する必要があります。
* 発射時のfor内のifの処理した後にbreakを入れる. * 自動移動や非表示の処理は、スペースキー押下とは関係ない箇所で実施.

// 自機の弾発射
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
            break
        }
    }
}
for i := 0; i < SHOT_MAX; i++ {
    // 弾の自動移動
    if g.my.shot[i].shotFlag {
        g.my.shot[i].shoty += 1.0
    }
    // 弾が画面外になったら非表示にする
    if g.my.shot[i].shotx > 640 || g.my.shot[i].shoty > 480 {
        g.my.shot[i].shotFlag = false
    }
}

もし、そのままforループして、IsKeyPressed()を使っていると、1度のスペースキー押下で3発分まとめて発射されてしまいます。

自機と敵が画面外に行かないようにする

前回の実装のままですと、敵がランダム移動するため左右どちらかに偏った場合、画面外に敵が消えてしまう状態になっています。
同じように自機に関しても画面外に出れるようになっているので、自機・敵共に画面から出ないように修正をしていきます。

自機の修正

まずは自機の移動処理にて、画面外には移動しないような処理に修正していきますが、一点注意が必要なのが、ebitengineの画像の原点座標が左上になっているため、右方向と下方向の座標判断には自機の画像のサイズも考慮する必要があります。

// 自機の移動
if ebiten.IsKeyPressed(ebiten.KeyUp) {
    if g.my.posy >= 0.0 {
        g.my.posy -= 0.5
    }
}
if ebiten.IsKeyPressed(ebiten.KeyDown) {
    if g.my.posy <= 480.0-g.my.drawHeight {
        g.my.posy += 0.5
    }
}
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
    if g.my.posx >= 0.0 {
        g.my.posx -= 0.5
    }
}
if ebiten.IsKeyPressed(ebiten.KeyRight) {
    if g.my.posx <= 640.0-g.my.drawWidth {
        g.my.posx += 0.5
    }
}

敵の修正

敵に関しては、今回はx座標でしか移動しないため、左右の画面外に行かないようにする修正のみを入れていきます。
もし、上下にも移動する場合は上下の画面外に行かないようにする対応も必要になってきます。

(ゲームの仕様次第になりますが、敵が画面外に出て消える移動を許容する場合は、今回のような画面外へ行かないように修正する必要はありません。画面外に出たら表示フラグをoffにして描画を行わないようにすることで描画処理の軽減などは実施する必要はあります。).

if enemyDirection == 1 {
    if g.enemy[i].posx > 0.0 {
        g.enemy[i].posx -= 5
    }
} else if enemyDirection == 2 {
    if g.enemy[i].posx < 640.0-g.enemy[i].drawWidth {
        g.enemy[i].posx += 5
    }
}

敵を倒した後に再登場させる&得点つける&自機の弾を消失させる

前回の実装では、敵を倒すとそのまま復活はしないので、復活するように修正しましょう。
また、スコア計算も行うようにして、敵と自機の弾が当たった場合は貫通せずに消失させるようにします。
(貫通する弾の場合は、消失する処理は不要となります)

得点計算

game構造体に得点を保持するscoreを定義しておきます。

type Game struct {
    my    baseData
    enemy [ENEMY_MAX]baseData
    score int // 追加
}

初期化時にscoreを0にして、ゲーム中に敵を倒すごとにscoreに値を追加していきます。

func (g *Game) Init() {
// 〜略〜
  g.score = 0 // 追加
}

func (g *Game) Update() error {
// 〜略〜
                // 自機の弾と敵の当たり判定(敵と自機の弾の数が一緒なので同じループで実施)
                if isHit(g.my.shot[j].shotx, g.my.shot[j].shoty, g.my.shot[j].drawWidth, g.my.shot[j].drawHeight, g.enemy[i].posx, g.enemy[i].posy, g.enemy[i].drawWidth, g.enemy[i].drawHeight) {
                    // 当たってるので敵消失
                    g.enemy[i].isHit = true
                    g.score += 100 // 追加
                }
// 〜略〜
}

score自体はデバッグ文字として表示しておきます。

func (g *Game) Draw(screen *ebiten.Image) {
// 〜略〜
    ebitenutil.DebugPrint(screen, fmt.Sprintf("score:%d", g.score))
    if g.my.isHit {
        ebitenutil.DebugPrint(screen, fmt.Sprintf("GameOver!!!:%d", g.score))
    }
}

敵を倒した場合に自機の弾を消す

自機の弾と敵の当たり判定の処理をしている箇所にて、自機の弾の表示をfalseにする処理を追加します。

func (g *Game) Update() error {
// 〜略〜
                // 自機の弾と敵の当たり判定(敵と自機の弾の数が一緒なので同じループで実施)
                if isHit(g.my.shot[j].shotx, g.my.shot[j].shoty, g.my.shot[j].drawWidth, g.my.shot[j].drawHeight, g.enemy[i].posx, g.enemy[i].posy, g.enemy[i].drawWidth, g.enemy[i].drawHeight) {
                    // 当たってるので敵消失
                    g.enemy[i].isHit = true
                    g.score += 100
                    g.my.shot[j].shotFlag = false // 追加
                }
// 〜略〜
}

敵の復活

敵が倒されて一定時間が経ったら復活するようにするために、タイマーを共通の構造体に追加します。

type baseData struct {
    posx        float64
    posy        float64
    isHit       bool
    drawImage   *ebiten.Image
    drawWidth   float64
    drawHeight  float64
    shot        [SHOT_MAX]shotData
    revivalTime time.Time  //追加
}

敵が倒された非表示になった時に現在時間より10秒後の時間を設定します。
また不具合があるので、自機の弾のxとy座標に自機の座標を入れ直しておきます。こちらに関しては後述します。

// 自機の弾と敵の当たり判定(敵と自機の弾の数が一緒なので同じループで実施)
if isHit(g.my.shot[j].shotx, g.my.shot[j].shoty, g.my.shot[j].drawWidth, g.my.shot[j].drawHeight, g.enemy[i].posx, g.enemy[i].posy, g.enemy[i].drawWidth, g.enemy[i].drawHeight) {
    // 当たってるので敵消失
    g.enemy[i].isHit = true
    g.score += 100
    g.my.shot[j].shotFlag = false
    g.my.shot[j].shotx = g.my.posx
    g.my.shot[j].shoty = g.my.posy
    t := time.Now()
    g.enemy[i].revivalTime = t.Add(time.Second + 10) //追加
}

Update()内にてタイマーを計算して特定の時間が経過したら再表示するようにします。

if !g.enemy[i].isHit {
    // 敵の移動(数秒同じ方向、左右ランダム)
〜略〜
} else {
    //時間経ってたら復活
    if g.enemy[i].revivalTime.Before(time.Now()) {
        g.enemy[i].isHit = false
    }
}

不具合の件


isHit()では座標しか見ていないため、敵を倒した後に自機にて弾を発射しないと弾自体は敵を倒した時の座標にいて見えない状態になっているだけのため、永遠に敵が存在しない弾との当たり判定が有効になってしまっています。
そのため、上記の動画のように敵が復活しては直ぐに倒されるという状態になっているため、弾の座標を初期化しています。

おまけ

今回の対応したソースはこちらになります。

まとめ

今回の修正で、得点計算と敵が復活するようになったので少しゲームらしくなってきたかと思います。
次回以降、演出部分に手を入れて更にゲームに近づけていってみます。


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

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