"x/mobile gaming"を解説する

このエントリーはKLab Advent Calendarの12日目の記事です。

@tenntennです。

12月6日(日)に渋谷でGo Conference 2015 Winterが開催されました。
私は運営として参加し、当日司会をしておりました。
KLabからもいつものエナジードリンクを提供させて頂き、参加者の皆様に飲んで頂けたかなと思います。

この記事ではGoチームのAndrew Gerrandさんのキーノート"x/mobile gaming"を解説したいと思います。
当日参加できなかった方も、参加したけどちょっと内容が難しかった方にも、"x/mobile gaming"を理解する助けとなればと思います。

なお、この記事で扱うソースコードは、Andrewさんのリポジトリから引用し、一部補足をいれたものです。
また、スクリーンショットに含まれるGopherの画像(を含むスプライト画像)は、リポジトリのREADMEに書いてある通り、Renee Frenchさんによるものです。
この画像は、Creative Commons Attributions 3.0ライセンスで保護されています。

なお、Go MobileはまだExperimentalなプロジェクトです。
今後も破壊的な変更が加わる恐れがあります。
そのためまだプロダクトへの導入は難しいでしょう。

この記事ではOSX Yosemiteで動作確認しています。
しかし、他の環境でも動作するようですので、ぜひ試してみてください。

STEP 0: Go Mobileをインストールする

まずは準備を行っていきたいと思います。

このキーノートは、Andrewさんが作ったdtというツールを使って、1コミットずつ差分を確認しながら実行していくものでした。
最終的には、"Flappy Gopher"というゲームができあがります。
1ステップずつ徐々にゲームが完成していく様子を見ながら、Go Mobileの基礎的な部分を理解できるという、とても良いプレゼンでした。
また、ゲームを作った事のない方にとっては、ゲームの作り方も学べる興味深いものだったかと思います。

まずは、dtをインストールしましょう。

$ go get github.com/adg/dt

$GOPATH/binPATHが通されていれば、dtコマンドを動かすことができます。

$ dt
usage: dt <head-rev>

つぎに、Go Mobileをインストールしましょう。
go getしてgomobile initをすればインストールできます。
Go Mobileの説明や詳細はこちらの記事を参考にすると良いでしょう。
なお、gomobile initは結構時間がかかるので、-vオプションをつけて進捗を確認できるようにしておくと分かりやすいと思います。
もしすでにGo Mobileが入っている方は、念のためgo get -uで更新しておくとよいでしょう。

$ go get golang.org/x/mobile/cmd/gomobile
$ gomobile init -v

さて、Go Mobileが入ったところで、さっそくサンプルを動かしてみましょう。
以下のように通常のGoのプログラムの実行方法と同じように、go runでMac上で動かすことができます。

$ cd $GOPATH/src/golang.org/x/mobile/example/basic
$ go run main.go

せっかくなので、Androidでも動かしてみましょう。
adbコマンドがMacに入っていることを確認して、以下のコマンドでapkをビルドしてAndroidにインストールします。

$ adb
Android Debug Bridge version 1.0.32

 -a                            - directs adb to listen on all interfaces for a connection
....
$ gomobile install golang.org/x/mobile/example/basic

しばらくすると、Androidの方にbasicという名前のアプリがインストールされているかと思います。

それでは、キーノートで使用された"Flappy Gopher"のリポジトリをgo getしてきましょう。

$ go get github.com/adg/game

一旦動かしてみましょう。

$ cd $GOPATH/src/github.com/adg/game
$ go run *.go

以下のような画面が出てきたはずです。
遊ぶのは最後のステップまで楽しみにとっておきましょう。

実行結果

さぁ、それでは1ステップずつ作っていきましょう。

STEP 1: Basic app boilerplate; display an empty window.

対応するコミット:4a9e64a5e0ae04c9535d3f760e25de93f8eeef87

以下のように、dtに最後のコミットから1つ前(最後のコミットはREADMEの追加のため)のコミット番号を渡します。

$ dt 7936d2

そうすると、プロンプトが出てきます。
選べる選択しは、qrnと数字です。
qdtの終了、rは実行、nは次のコミットへ移動です。
数字を入力するとプロンプトの上に表示されているファイル一覧の差分(1つ前のコミットからの)を確認できます。

Basic app boilerplate; display an empty window.

Changed files:
  [1] main.go

  Choice [qrn1]:

一番上に書いてある「Basic app boilerplate; display an empty window.」は、コミットメッセージです。
このコミットメッセージは最初のコミットのものなので、現在は最初のコミットにいることが分かります。

それでは実行してみましょう。

Basic app boilerplate; display an empty window.

Changed files:
  [1] main.go

  Choice [qrn1]: r

STEP1の実行結果

空のウィンドウですね。
main.goを見ると、アプリの雛形しか書かれてないことが分かります。
main関数内でapp.Mainメソッドを呼び出し、その中でイベントループを回しています。
イベントは、a.Eventsメソッドで取得できるチャネルから送られてきます。
イベントについてはこちらの記事を見て頂けると詳細に説明しています。

func main() {
    app.Main(func(a app.App) {
        for e := range a.Events() {
            switch e := a.Filter(e).(type) {
            case lifecycle.Event:
                switch e.Crosses(lifecycle.StageVisible) {
                case lifecycle.CrossOn:
                    // App visible.
                case lifecycle.CrossOff:
                    // App no longer visible.
                }
            case paint.Event:
                a.Publish()
            }
        }
    })
}

main.go以外にも、placeholder-sprites.pngというファイルも追加されています。
こちらはスプライト画像で、後のステップでキャラクターや地面などに使われる画像です。
最初のコミットでは、色がベタ塗りしてありますが、最終的にはGopherの画像などになります。

STEP 2: Add OpenGL-based sprite engine.

対応するコミット:43ef6bb2400cff8bbf84d1c4168e7fa7244d19a7

onStart、onStop、onPaintの追加

このステップでは、onStartonStoponPaintの3つの関数が追加されています。

var (
        startTime = time.Now()
        images    *glutil.Images
        eng       sprite.Engine
        scene     *sprite.Node
)

func onStart(glctx gl.Context) {
        images = glutil.NewImages(glctx)
        eng = glsprite.Engine(images)
        scene = &sprite.Node{}
        eng.Register(scene)
        eng.SetTransform(scene, f32.Affine{
                {1, 0, 0},
                {0, 1, 0},
        })
}

func onStop() {
        eng.Release()
        images.Release()
}

func onPaint(glctx gl.Context, sz size.Event) {
        glctx.ClearColor(1, 1, 1, 1)
        glctx.Clear(gl.COLOR_BUFFER_BIT)
        now := clock.Time(time.Since(startTime) * 60 / time.Second)
        eng.Render(scene, now, sz)
}

onStart

onStart関数では、スプライトエンジンの初期化を行っています。
Go Mobileでは、2Dのスプライトエンジンを提供しています。
スプライトエンジンを使えば、簡単に2Dのシーングラフを構築することができます。
spriteパッケージでは、スプライトエンジンを表すEngineインタフェースを提供しています。
具体的な実装はOpenGL以外にも取れるように別のパッケージに任せてあります。
OpenGLの実装は、sprite/glspriteにあります。
なお、スプライトエンジンの詳細についても、こちらの記事で説明してあります。

onStart関数では、以下の手順でスプライトエンジンの初期化を行っています。

  • OpenGLのコンテキストからImagesオブジェクトの作成
  • エンジンオブジェクトの生成
  • シーンのルートノードを作成
  • ルートノードをエンジンオブジェクトに登録
  • ルートの初期位置やスケールを設定する

Imagesオブジェクトは、OpenGLのテクスチャを作るためのオブジェクトです。
内部にシェーダーなどを持ちテクスチャを生成するために使います。

spriteパッケージを使って2Dのシーンを描画するためには、シーングラフを構築していきます。
ここでは、シーングラフのルートノード(scene)を作成し、エンジンオブジェクトに登録しています。
シーングラフのノードには、アフィン変換行列を設定することでノードに設定したテクスチャがどの位置にどのような大きさや角度で描画されるのか決めます。
最終的なテクスチャの描画位置などは、ルートからそのノードまでのアフィン変換行列を掛けあわせた結果が用いられます。

onStop

onStopでは、エンジンオブジェクトとテクスチャを破棄しています。

onPaint

onPaintでは、背景を白く塗りつぶし、シーンのレンダリングを行っています。
Renderメソッドに渡しているnowという変数は、現在のフレームを表しています。
この場合は、60FPSで計算されています。
szについては後ほど説明します。

イベントのハンドリング

このステップでは、各イベントをハンドリングするコードも追加されています。

lifecycle.Event

lifecycle.Eventをハンドリングすることで、画面が表示された場合や表示されなくなった場合に処理をすることができます。
この場合は、lifecycle.StageVisibleというStageに入った(またはそこから出た)かを見ています。
こうすることで、画面が表示され始めた、または表示されなくなったタイミングで処理を行えます。

lifecycle.CrossOnは、そのStageに入ったことを表し、この場合だと画面が表示されたタイミングになります。
画面が表示されると、OpenGLのコンテキストを取得しonStartを呼び出します。
その後、paint.Eventを発生させ、描画を促します。

lifecycle.CrossOffは、そのStageから出たことを表し、この場合だと画面が表示されなくなったタイミングになります。
画面が表示されなくなると、onStopを呼び保持しているリソースを破棄します。
そして、OpenGLのコンテキストも破棄されます。

lifecycle.Eventについては、少し複雑で分かりづらいかと思います。
そのため、こちらの記事を見ていただけると図で説明していますので理解しやすいでしょう。

case lifecycle.Event:
        switch e.Crosses(lifecycle.StageVisible) {
        case lifecycle.CrossOn:
            // App visible.
            glctx, _ = e.DrawContext.(gl.Context)
            onStart(glctx)
            a.Send(paint.Event{})
        case lifecycle.CrossOff:
            // App no longer visible.
            onStop()
            glctx = nil
        }

size.Event

size.Eventは、画面の大きさが変わった場合や画面を回転させた場合に発生するイベントです。
アプリ起動時に1度はpaint.Eventが発生する前に必ず呼ばれます。
szは、サイズ情報が格納されたもので、シーングラフを描画する際に用いられます。

case size.Event:
        sz = e

paint.Event

paint.Eventは描画イベントです。
ここでは、OpenGLのコンテキストがなかったりOSレイヤーから送られてきた描画イベントは無視しています。
a.Send(paint.Event{})のように、自前で発生させた描画イベントの場合は、e.Externalfalseになります。

onPaintメソッドを呼び出すことでシーングラフを構築し描画を行っています。
最終的に画面に出力するためには、Publishメソッドを呼び出す必要があります。

描画が一通り終わったら、再度描画イベントを走らせて次のフレームに移ります。

case paint.Event:
        if glctx == nil || e.External {
            continue
        }
        onPaint(glctx, sz)
        a.Publish()
        a.Send(paint.Event{}) // keep animating

実行結果

それではこのステップの実行結果を見てみましょう。

STEP2の実行結果

背景が白くなりましたね。

STEP 3: Add game.go and Game type to hold game logic.

対応するコミット:0cf230e77f624ee96a0e87dd482e8c35b282f3e1

game.goが追加されています。
このファイルには、ゲームロジックが記述されるGame型が構造体として宣言されています。
ここではまだ、Game構造体は新しいシーングラフを作成するSceneメソッドしか持っていません。
シーングラフの初期化は、onStartから移動されています。

実行してみましょう。

STEP3の実行結果

当然ながら描画部分をいじってないので何も変わりません。

STEP 4: Load textures from a PNG file.

対応するコミット:99aa823b17a8a4f6d2a63ea58bc2023ab86192b5

テクスチャのロード

game.goloadTexturesという関数が追加されています。
この関数は画像ファイルからテクスチャを読み込み、さらにサブテクスチャに分けて返します。

画像の読み込みは、asset.Open関数を使います。
os.Openと似たような関数ですが、モバイルアプリの場合、OSによってアプリごとに割り当てられるファイル領域が異なります。
その差分を吸収するためにasset.Openを使います。
もちろん、正しいパスを渡せばos.Openも使えます。
asset.Openは、mainパッケージがあるディレクトリ直下のassetsというディレクトリの中からファイルを探します。
この場合は、STEP1で追加したplaceholder-sprites.pngという画像を開いています。

asset.Openの返すasset.Fileインタフェースは、Readメソッドを持っているためimageパッケージのデコーダにそのまま渡すことができます。
ここでは、image.Decodeimage.Image型としてメモリ上に展開されます。

image.Image型をスプライトエンジンで扱うために、テクスチャに変換します。
LoadTextureメソッドを使うと渡したimage.Image型のオブジェクトに対応するテクスチャを作成してくれます。

シーングラフのノードには、ロードしたテクスチャをそのまま貼り付けるのではなく、サブテクスチャに分割して貼り付けなければいけません。
そのためloadTextures関数では、ロードしたテクスチャをサブテクスチャに分割し、スライスとして返しています。
ここでは、左端の128x128の青い部分をGopherとして切り出しています。

func loadTextures(eng sprite.Engine) []sprite.SubTex {
    a, err := asset.Open("placeholder-sprites.png")
    if err != nil {
        log.Fatal(err)
    }
    defer a.Close()

    m, _, err := image.Decode(a)
    if err != nil {
        log.Fatal(err)
    }
    t, err := eng.LoadTexture(m)
    if err != nil {
        log.Fatal(err)
    }

    const n = 128
    return []sprite.SubTex{
        texGopher: sprite.SubTex{t, image.Rect(1+0, 0, n-1, n)},
    }
}

Gopherノード

Game型のSceneメソッドに新たにGopherを表すノードが追加されています。
ノードを作成するために、newNodeというヘルパー関数を作っています。
newNode関数では、ノードの作成とエンジンオブジェクトへの登録、ルートノードの子要素への追加を行っています。
ノードの初期化時にArrangerというフィールドを設定しています。
Arrangerフィールドには、Arrangeメソッドを持つsprite.Arrangerインタフェースが設定できます。
このArrangeメソッドは、エンジンオブジェクトのRenderメソッドが呼び出され際に、毎フレーム呼びだされます。
そのため、各フレームごとにノードに設定するサブテクスチャや位置を変えることができます。
なお、簡単のためにここではArrangerインタフェースを実装したarrangerFuncという関数型を作っています。

Gopherノードには、loadTexturesで作成したサブテクスチャのうち、青色の画像の部分が設定されています。
ノードにサブテクスチャを設定するには、SetSubTexメソッドを用います。
ノードの位置は、SetTransformで設定されます。
この場合、画面を16x16で分割した場合に、(1,0)の位置に、16x16の大きさで描画するという設定になっています。

func (g *Game) Scene(eng sprite.Engine) *sprite.Node {
    texs := loadTextures(eng)

    scene := &sprite.Node{}
    eng.Register(scene)
    eng.SetTransform(scene, f32.Affine{
        {1, 0, 0},
        {0, 1, 0},
    })

    newNode := func(fn arrangerFunc) {
        n := &sprite.Node{Arranger: arrangerFunc(fn)}
        eng.Register(n)
        scene.AppendChild(n)
    }

    // The gopher.
    newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        eng.SetSubTex(n, texs[texGopher])
        eng.SetTransform(n, f32.Affine{
            {tileWidth, 0, tileWidth * gopherTile},
            {0, tileHeight, 0},
        })
    })

    return scene
}

実行結果

それでは実行してみましょう。

STEP4の実行結果

まだGopherではないですが、青い画像が描画されましたね。

STEP 5: Draw the ground.

対応するコミット:a419e647280f8df94e697127de14bd86326807f2

このステップでは地表ノードが追加されています。

地表を表すサブテクスチャは、スプライト画像の左から4番目の紫の部分です。
loadTexturesで返すスライスに追加されています。

return []sprite.SubTex{
        texGopher: sprite.SubTex{t, image.Rect(1+0, 0, n-1, n)},
        texGround: sprite.SubTex{t, image.Rect(1+n*3, 0, n*4-1, n)},
}

Game型のSceneメソッドで地表ノードを作成しています。
画面の左端から順に、g.groundY[i]の高さにノードを描画しています。

// The ground.
for i := range g.groundY {
        i := i
        newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
                eng.SetSubTex(n, texs[texGround])
                eng.SetTransform(n, f32.Affine{
                        {tileWidth, 0, float32(i) * tileWidth},
                        {0, tileHeight, g.groundY[i]},
                })
        })
}

g.groundYは、地表ノードのY座標のスライスです。
新たに追加されたresetメソッドで初期化されています。

func (g *Game) reset() {
        for i := range g.groundY {
                g.groundY[i] = initGroundY
        }
}

実行してみましょう。

STEP5の実行結果

紫色の地表が描画されましたね。

STEP 6: Add a function to update game state for each frame.

対応するコミット:ccd80d34015a36064788476bb89dc8e65b25f518

Game型にUpdateメソッドとcalcFrameメソッドが追加されています。
Updateは最後に更新したフレームから現在のフレームまで、calcFrameメソッドを呼び出します。
calcFrameメソッドは、ここでは何もしませんが、そのフレームでのゲームの状態を計算するために用いられるようです。

func (g *Game) Update(now clock.Time) {
        // Compute game states up to now.
        for ; g.lastCalc < now; g.lastCalc++ {
                g.calcFrame()
        }
}

func (g *Game) calcFrame() {
        // calculate state for next frame
}

Updateメソッドは、main.goonPaint内で呼ばれています。

func onPaint(glctx gl.Context, sz size.Event) {
        glctx.ClearColor(1, 1, 1, 1)
        glctx.Clear(gl.COLOR_BUFFER_BIT)
        now := clock.Time(time.Since(startTime) * 60 / time.Second)
        game.Update(now)
        eng.Render(scene, now, sz)
}

このステップでは、前のステップと実行結果は変わらないので省略します。

STEP 7: Generate new ground tiles.

対応するコミット:6075f612890f3a6667c98b55c37b3317d4fd79c7

このステップでは、ついに画面が動き出します。
前のステップで作ったcalcFrameメソッドの中でcalcScrollメソッドが呼び出されています。
calcScrollメソッドでは、画面を横スクロールさせる処理が行われます。
具体的には、20フレームに1回(60FPSで1秒に3回)、あたらしい地表を作ります。

新しい地表の作成は、newGroundTileメソッドで行います。
nextGroundYメソッドで新しく作られる地表のY座標を計算し、groundYスライスをひとつずつずらすことで右から左へと地表をスクロールしています。

func (g *Game) calcFrame() {
        g.calcScroll()
}

func (g *Game) calcScroll() {
        // Create new ground tiles 3 times a second.
        if g.lastCalc%20 == 0 {
                g.newGroundTile()
        }
}

func (g *Game) newGroundTile() {
        // Compute next ground y-offset.
        next := g.nextGroundY()

        // Shift ground tiles to the left.
        copy(g.groundY[:], g.groundY[1:])
        g.groundY[len(g.groundY)-1] = next
}

func (g *Game) nextGroundY() float32 {
        prev := g.groundY[len(g.groundY)-1]
        if change := rand.Intn(groundChangeProb) == 0; change {
                return (groundMax-groundMin)*rand.Float32() + groundMin
        }
        return prev
}

main.goへの変更は、乱数の初期化だけです。

さて、動かしてみましょう。
今回は動きがあるので、gifファイルを貼っています。
地表を表す紫色のタイルが右から左へとスクロールしている事が分かると思います。

STEP7の実行結果

STEP 8: Draw the earth beneath the ground.

対応するコミット:6a85814a913aaee1fc6857c7b0b88c4b62e0c295

前のステップでは、地表だけを描画してきました。
横スクロールのゲームだと地表だけはなく地中も描画されることが多いと思います。
このステップでは、地表に加え地中も描画するようにSceneメソッドに処理を追加しています。

以下の部分が、地中を描画している部分です。
縦方向は画面一杯(tileHeight * tilesY)の大きさにし、それを地表の真下(g.groundY[i] + tileHeight)までずらすことで地中を作っています。

// The earth beneath.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        eng.SetSubTex(n, texs[texEarth])
        eng.SetTransform(n, f32.Affine{
                {tileWidth, 0, float32(i) * tileWidth},
                {0, tileHeight * tilesY, g.groundY[i] + tileHeight},
        })
})

地中のテクスチャは以下のようにスプライト画像の右から2番目の水色の部分が使用されています。

texEarth:  sprite.SubTex{t, image.Rect(1+n*4, 0, n*5-1, n)},

それでは、実行してみましょう。
前のステップで横スクロールするようになった地表の下に水色のタイルが描画されていることが分かります。

Gopherになるはずの青色のタイルは浮いたままですね。

STEP8の実行結果

STEP 9: Scroll the ground smoothly.

対応するコミット:491933c77eb328edcc34b7594691ff7edf01f65f

横スクロールするようになったはいいですが、スクロールがガタガタで気になります。
そこでこのステップでは、スクロールをもっとスムーズになるように処理を追加しています。

新しく以下の2つの定数が追加されています。
初速度と加速度です。スクロールがだんだんと加速していくことが予想できます。

const (
...
        initScrollV = 1     // initial scroll velocity
        scrollA     = 0.001 // scroll accelleration
...
)

スムーズなスクロールを実現するために、Game構造体にscrollフィールドを追加してます。
scrollフィールドは、xvというフィールドを持つ構造体です。
読者の方の中には初めて見る書き方かもしれませんが、単純に型を型リテラルで書いているだけです。

type Game struct {
        scroll struct {
                x float32 // x-offset
                v float32 // velocity
        }
        groundY  [tilesX + 3]float32 // ground y-offsets
        lastCalc clock.Time          // when we last calculated a frame
}

描画部分にscroll.xで描画位置を微調整していることが分かります。

// The top of the ground.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        eng.SetSubTex(n, texs[texGround])
        eng.SetTransform(n, f32.Affine{
                {tileWidth, 0, float32(i)*tileWidth - g.scroll.x},
                {0, tileHeight, g.groundY[i]},
        })
})
// The earth beneath.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        eng.SetSubTex(n, texs[texEarth])
        eng.SetTransform(n, f32.Affine{
                {tileWidth, 0, float32(i)*tileWidth - g.scroll.x},
                {0, tileHeight * tilesY, g.groundY[i] + tileHeight},
        })
})

scroll.xの更新は、calcScrollで行われています。
単純にscroll.vscrollAを足し、scroll.xに速度scroll.vを足しているだけです。
そして、スクロールした分だけ新しい地面を作っています。

func (g *Game) calcScroll() {
        // Compute velocity.
        g.scroll.v += scrollA

        // Compute offset.
        g.scroll.x += g.scroll.v

        // Create new ground tiles if we need to.
        for g.scroll.x > tileWidth {
                g.newGroundTile()
        }
}

実行してみましょう。

STEP9の実行結果

先ほどよりスクロールがスムーズになっていることが分かるかと思います。
放っておくと、スクロールがどんどん加速していきます。

STEP 10: Apply gravity to the gopher.

対応するコミット:e52508330f11fde4e41f9581629ba5bafcc02614

Gopherもいつまでも浮いてられないと思いますので、そろそろ重力を取り入れましょう。

定数にgravityが追加されています。
この定数がGopherに重力として働きます。

gravity     = 0.1   // gravity

Gopherにも状態を持たせる必要があるため、Game構造体にgopherフィールドが追加されています。
gopherフィールドも構造体になっていて、Y座標をy、速度(縦方向)をvに保持します。

type Game struct {
        gopher struct {
                y float32 // y-offset
                v float32 // velocity
        }
        scroll struct {
                x float32 // x-offset
                v float32 // velocity
        }
        groundY  [tilesX + 3]float32 // ground y-offsets
        lastCalc clock.Time          // when we last calculated a frame
}

GopherのY座標を変数にしたため、SetTransformの部分も変更されています。
g.gopher.yを使用するになっています。

// The gopher.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        eng.SetSubTex(n, texs[texGopher])
        eng.SetTransform(n, f32.Affine{
                {tileWidth, 0, tileWidth * gopherTile},
                {0, tileHeight, g.gopher.y},
        })
})

Gopherの座標を計算するためにcalcGopherという関数が追加され、calcFrame内で呼ばれています。
これも前のステップのようにただ速度に重力を足して、Y座標に速度を足しているだけです。

func (g *Game) calcGopher() {
        // Compute velocity.
        g.gopher.v += gravity

        // Compute offset.
        g.gopher.y += g.gopher.v
}

動かしてみましょう。

STEP10の実行結果

あっというまにGopherが落ちていきました。

STEP 11: Detect collision between the gopher and the ground.

対応するコミット:be4ce097afa5401fb90596967cfc2c2b82096ad7

前のステップで、Gopherに速度を持たせたものの、地面にぶつからずそのまま通りすぎてしまいました。
そこでこのステップでは、Gopherと地面の当たり判定の処理を追加しています。

clampToGroundというメソッドが追加され、この中で当たり判定の処理を行っています。
地面にめり込まないようにはみ出そうな場合は、速度を0にし、地表の上にGopherがくるようになっています。

func (g *Game) clampToGround() {
        // Compute the minimum offset of the ground beneath the gopher.
        minY := g.groundY[gopherTile]
        if y := g.groundY[gopherTile+1]; y < minY {
                minY = y
        }

        // Prevent the gopher from falling through the ground.
        maxGopherY := minY - tileHeight
        if g.gopher.y >= maxGopherY {
                g.gopher.v = 0
                g.gopher.y = maxGopherY
        }
}

STEP11の実行結果

STEP 12: Let the gopher jump.

対応するコミット:bbe69ef8e8874637dc48333c19afb3de30c57a22

ここまでGopherはただ横に走るだけでジャンプできませんでした。
このステップではジャンプするようにタッチイベントとキーイベントをハンドリングします。

タッチイベントは、touch.Eventとして送られてきます。
イベントオブジェクトのTypeフィールドに画面に触れたのか離したのかを取ることができます。
触れた場合は、game.Pressメソッドにtrueを渡し、離した場合はgame.Pressメソッドにfalseを渡しています。

キーイベントは、key.Eventとして送られてきます。
イベントオブジェクトのCodeに押されたキーのコードが送られてきます。
この場合は、スペースキー以外は無視しています。
キーイベントの方もDirectionで押したのか離したのかを取得し、押した場合はtrueを離した場合はfalsegame.Pressに渡しています。

for e := range a.Events() {
       switch e := a.Filter(e).(type) {
...
       case touch.Event:
               if down := e.Type == touch.TypeBegin; down || e.Type == touch.TypeEnd {
                       game.Press(down)
               }
       case key.Event:
               if e.Code != key.CodeSpacebar {
                       break
               }
               if down := e.Direction == key.DirPress; down || e.Direction == key.DirRelease {
                       game.Press(down)
               }
       }
}

このステップでは、Pressメソッドが追加されています。
downtrueの場合は速度にjumpVが設定されます。
jumpVは新しく定義された定数で、マイナスの値です。

downfalseの場合は、飛んでいる最中(マイナスの値)の場合は速度を0にします。
すでに落ちている最中の場合は何もしません。

func (g *Game) Press(down bool) {
        if down {
                // Make the gopher jump.
                g.gopher.v = jumpV
        } else {
                // Stop gopher rising on button release.
                if g.gopher.v < 0 {
                        g.gopher.v = 0
                }
        }
}

それでは、実行してみましょう。

STEP12の実行結果

スペースキーを押すとGopherが飛びます!
しかし、連打するとどこか遠くに行ってしまいます。。

STEP 13: Don't let the gopher jump from mid-air.

対応するコミット:f1a037772b41ee0752ad3d2d03630501300cff52

Gopherが空中でジャンプしないようにしましょう。

Game構造体のgopherフィールドの構造体にatRestというフラグが追加されています。

type Game struct {
        gopher struct {
                y      float32 // y-offset
                v      float32 // velocity
                atRest bool    // is the gopher on the ground?
        }
        scroll struct {
                x float32 // x-offset
                v float32 // velocity
        }
        groundY  [tilesX + 3]float32 // ground y-offsets
        lastCalc clock.Time          // when we last calculated a frame
}

このatResttrueの場合は、Press(true)が実行されても反応しないようになっています。

func (g *Game) Press(down bool) {
        if down {
                if g.gopher.atRest {
                        // Gopher may jump from the ground.
                        g.gopher.v = jumpV
                }
        } else {
                // Stop gopher rising on button release.
                if g.gopher.v < 0 {
                        g.gopher.v = 0
                }
        }
}

atRestは当たり判定の際に、地面に触れてない場合はfalseになります。
地面に接触した場合にtrueが設定され、ジャンプができるようになります。

func (g *Game) clampToGround() {
        // Compute the minimum offset of the ground beneath the gopher.
        minY := g.groundY[gopherTile]
        if y := g.groundY[gopherTile+1]; y < minY {
                minY = y
        }

        // Prevent the gopher from falling through the ground.
        maxGopherY := minY - tileHeight
        g.gopher.atRest = false
        if g.gopher.y >= maxGopherY {
                g.gopher.v = 0
                g.gopher.y = maxGopherY
                g.gopher.atRest = true
        }
}

実行結果を見てみましょう。

STEP13の実行結果

gifで見ても分かりづらいですが、地面に触れている時しかジャンプしてないことが分かると思います。
gifでは伝わらないですが、実はスペースを連打しています。

STEP 14: Detect crashes, allow the gopher to die.

対応するコミット:d16bc6c27d061e5fc43dbe36e7441bc50b77da2b

ここまでのGopherはかなり頑丈で壁にぶつかっても死にません。
しかし、このままではゲームにならないので、ぶつかったら死ぬようにしましょう。

gopherdeadというフィールドを追加し、生きている時はfalseが入り、死んだ場合にはtrueが入ります。
この値によってGopherノードに設定するサブテクスチャを変えています。
死んだ場合には、texs[texGopherDead]が設定されるように更新されています。

switch {
case g.gopher.dead:
        eng.SetSubTex(n, texs[texGopherDead])
default:
        eng.SetSubTex(n, texs[texGopher])
}

死んだ時のサブテクスチャには、スプライト画像の左から2番目の赤い部分を用います。

texGopherDead: sprite.SubTex{t, image.Rect(1+n, 0, n*2-1, n)},

死んだ場合には、Pressの中で何も処理を行わないようになっています。

func (g *Game) Press(down bool) {
        if g.gopher.dead {
                // Player can't control a dead gopher.
                return
        }
...

壁にぶつかった場合、Gopherは死にます。
そのため、地面を作る際にGopherにぶつかるかどうかをチェックしています。

func (g *Game) calcScroll() {
        // Compute velocity.
        g.scroll.v += scrollA

        // Compute offset.
        g.scroll.x += g.scroll.v

        // Create new ground tiles if we need to.
        for g.scroll.x > tileWidth {
                g.newGroundTile()

                // Check whether the gopher has crashed.
                // Do this for each new ground tile so that when the scroll
                // velocity is >tileWidth/frame it can't pass through the ground.
                if !g.gopher.dead && g.gopherCrashed() {
                        g.killGopher()
                }
        }
}

calcScrollの中で使われているgopherCrashedkillGopherは新しく追加されたメソッドです。
gopherCrashedメソッドは、右となりの壁とあたっているかチェックするメソッドです。
一方、killGopherはその名通りGopherを死んだ状態にするメソッドです。

func (g *Game) gopherCrashed() bool {
        return g.gopher.y+tileHeight > g.groundY[gopherTile+1]
}

func (g *Game) killGopher() {
        g.gopher.dead = true
}

これで壁にぶつかるとGopherが死んでしまうようになりました。
実行して見てみましょう。

STEP14の実行結果

うまくジャンプして壁を超えた場合は死なずにすみますが、壁にぶつかるとGopherが赤くなって死んでしまうことが分かります。

STEP 15: Bounce gopher when it dies.

対応するコミット:ce04396bedd07e61ddf0e02e7df2827c9b2e9a9d

Gopherが死んだあと動けなくなるので、その後そのままずっと引きずられてしまっています。
これでは可哀想なので、死んだら画面から外してやりましょう。

死んだ時にバウンドさせて、画面の下に落としてやると雰囲気がでますよね。
killGopherの時に速度をジャンプした時と同じように設定してやります。

func (g *Game) killGopher() {
        g.gopher.dead = true
        g.gopher.v = jumpV // Bounce off screen.
}

このままだと、地面に衝突してしまうので、地面を突き抜けるようにclampToGroundを修正します。

func (g *Game) clampToGround() {
        if g.gopher.dead {
                // Allow the gopher to fall through ground when dead.
                return
        }
...

さて、これで横スクロールゲームの雰囲気が出るようになりました。
動かしてみましょう。

STEP15の実行結果

雰囲気が出てきました。

STEP 16: Slow scrolling after the gopher dies.

対応するコミット:c364288ecb583711734f8f8b49ff742bab2c733b

Gopherが死んだあとずっとスクロールが動いてしまっているので止めましょう。
deadScrollAというマイナスの値の定数を導入し、Gopherが死んだらゆっくりスクロールが止まるようにします。

func (g *Game) calcScroll() {
        // Compute velocity.
        if g.gopher.dead {
                // Decrease scroll speed when the gopher dies.
                g.scroll.v += deadScrollA
                if g.scroll.v < 0 {
                        g.scroll.v = 0
                }
        } else {
                // Increase scroll speed.
                g.scroll.v += scrollA
        }
...

実行してみましょう。

STEP16の実行結果

ゆっくりスクロールが止まっていることが分かります。

STEP 17: Restart the game after the gopher dies.

対応するコミット:86ada5b6814f1d5bd41e973377e9a98ff95b775d

ここまでの実装だと一度Gopherが死んでしまうと画面が固まってしまいます。
多くのゲームはタイトル画面に戻ったり、再度ゲームができるように作られています。
このゲームでも再度ゲームができるようにしましょう。

gopherフィールドに、死んだ時間を記録しそこからdeadTimeBeforeReset時間だけたったらリセットするようになっています。

killGopherメソッドに死んだ時間を記録する処理が追加されています。

func (g *Game) killGopher() {
        g.gopher.dead = true
        g.gopher.deadTime = g.lastCalc
        g.gopher.v = jumpV // Bounce off screen.
}

一定時間経つとリセットする処理は、Updateメソッドで行っています。

func (g *Game) Update(now clock.Time) {
        if g.gopher.dead && now-g.gopher.deadTime > deadTimeBeforeReset {
                // Restart if the gopher has been dead for a while.
                g.reset()
        }
...

動かしてみましょう。

STEP17の実行結果

死んだ後に一定時間経つとリセットされていることが分かります。

STEP 18: Let the gopher "flap" while jumping.

対応するコミット:ecb950d50c2feef2d991d5679d539528898a20da

空中でジャンプできないようにしましたが、ゲームなので多少空中で維持できるようにしたいですね。

gopherフィールドの構造体にflappedというフィールドが追加されています。
ジャンプしているときに1度だけ"flap"することができます。
"flap"するとGopherの速度をflapVに設定します。

func (g *Game) Press(down bool) {
        if g.gopher.dead {
                // Player can't control a dead gopher.
                return
        }

        if down {
                switch {
                case g.gopher.atRest:
                        // Gopher may jump from the ground.
                        g.gopher.v = jumpV
                case !g.gopher.flapped:
                        // Gopher may flap once in mid-air.
                        g.gopher.flapped = true
                        g.gopher.v = flapV

flappedclampToGroundで地面に触れた時にfalseに設定されます。

"flap"している間も画像を変えたいため、別途サブテクスチャを設定しています。
使用するサブテクスチャは、スプライト画像の左から3番目の緑色の部分です。

texGopherFlap: sprite.SubTex{t, image.Rect(1+n*2, 0, n*3-1, n)},

実行してみましょう。

STEP18の実行結果

ジャンプした後にちょっとだけ上に上がることができるはずです。

STEP 19: Add small variance ("wobble") to the ground heights.

対応するコミット:7c012ab3592ef88c1c91b7bb327d01b8cf6f93ff

ここまでの地面は平らですが、これだとちょっと不自然ですね。
乱数で多少ガタガタさせましょう。

func (g *Game) nextGroundY() float32 {
        prev := g.groundY[len(g.groundY)-1]
        if change := rand.Intn(groundChangeProb) == 0; change {
                return (groundMax-groundMin)*rand.Float32() + groundMin
        }
        if wobble := rand.Intn(groundWobbleProb) == 0; wobble {
                return prev + (rand.Float32()-0.5)*(tileHeight/3)
        }
        return prev
}

実行してみましょう。

STEP19の実行結果

おっと小さな段差でGopherが死んでしまいましたね。

STEP 20: Let the gopher climb small steps without jumping.

対応するコミット:741d475ec0dc01584ca3e0d5a78570fe2c0e3fb1

Gopherはそんなに弱くはないので、1タイルの1/3くらいの大きさの壁にぶつかっても死なないようにしましょう。
climbGraceは、1タイルの高さ(16)の1/3の大きさで、これくらいの誤差は許容するようにしました。

func (g *Game) gopherCrashed() bool {
        return g.gopher.y+tileHeight-climbGrace > g.groundY[gopherTile+1]
}

また、ガタガタさせる部分もclimbGraceに収まるようにします。

func (g *Game) nextGroundY() float32 {
        prev := g.groundY[len(g.groundY)-1]
        if change := rand.Intn(groundChangeProb) == 0; change {
                return (groundMax-groundMin)*rand.Float32() + groundMin
        }
        if wobble := rand.Intn(groundWobbleProb) == 0; wobble {
                return prev + (rand.Float32()-0.5)*climbGrace
        }
        return prev
}

実行してみましょう。

STEP20の実行結果

ちょっとの段差では、死ななくなりましたね。

STEP 21: Replace placeholder sprites with Renee French illustrations.

対応するコミット:e1b09ed76e81b2fc5c75c1a02de3ec48e8e8a3f6

青い四角に愛着が湧いてきたところだと思いますが、せっかくなので、Renee Frenchさんの描いた可愛らしいGopherに変えましょう。

STEP21の実行結果

一気に本格的なゲームになってきました。

Gopherの背景が透過処理されていないのが気になりますね。
onPaintの先頭に3行を追加すると透過処理がされると思います。

glctx.Enable(gl.BLEND)
glctx.BlendEquation(gl.FUNC_ADD)
glctx.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)

STEP 22: Adjust size and position of the gopher.

対応するコミット:34018d0690756829c549696f0efba5a85e7a49a6

ちゃんとした画像に変えてみると、Gopherが小さいことに気づきます。
以下のようにもう少し大きくしてやりましょう。

// The gopher.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        switch {
        case g.gopher.dead:
                eng.SetSubTex(n, texs[texGopherDead])
        case g.gopher.v < 0:
                eng.SetSubTex(n, texs[texGopherFlap])
        default:
                eng.SetSubTex(n, texs[texGopher])
        }
        eng.SetTransform(n, f32.Affine{
                {tileWidth * 2, 0, tileWidth*(gopherTile-1) + tileWidth/8},
                {0, tileHeight * 2, g.gopher.y - tileHeight + tileHeight/4},
        })
})

動かしてみましょう。

STEP22の実行結果

見やすくなりましたね。

STEP 23: Use varying ground sprites.

対応するコミット:e8ffdf323bf4934561aa2b17dd42f1a59d64dedf

もう少しクォリティを上げるために、地表のテクスチャを複数用意してランダムに生成するようにしましょう。
Game構造体にgroundTexというフィールドを用意し、座標によってテクスチャを変えてやります。

func (g *Game) newGroundTile() {
        // Compute next ground y-offset.
        next := g.nextGroundY()
        nextTex := randomGroundTexture()

        // Shift ground tiles to the left.
        g.scroll.x -= tileWidth
        copy(g.groundY[:], g.groundY[1:])
        copy(g.groundTex[:], g.groundTex[1:])
        last := len(g.groundY) - 1
        g.groundY[last] = next
        g.groundTex[last] = nextTex
}

ここで使われているrandomGroundTexture関数は単に乱数を返す関数です。

func randomGroundTexture() int {
        return texGround1 + rand.Intn(4)
}

実行してみましょう。

STEP23の実行結果

あまり変化はありませんが、少し自然になったかなと思います。

STEP 24: Animate the gopher.

対応するコミット:79e6d5e2d53eba5c8a43743af9706a8cee8b0f94

Gopherをアニメーションをさせて完成度を高めます。
Arrangerでフレームによって使用するサブテクスチャを変えることでアニメーションを実現します。

// The gopher.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        var x int
        switch {
        case g.gopher.dead:
                x = frame(t, 16, texGopherDead1, texGopherDead2)
        case g.gopher.v < 0:
                x = frame(t, 4, texGopherFlap1, texGopherFlap2)
        case g.gopher.atRest:
                x = frame(t, 4, texGopherRun1, texGopherRun2)
        default:
                x = frame(t, 8, texGopherRun1, texGopherRun2)
        }
        eng.SetSubTex(n, texs[x])
        eng.SetTransform(n, f32.Affine{
                {tileWidth * 2, 0, tileWidth*(gopherTile-1) + tileWidth/8},
                {0, tileHeight * 2, g.gopher.y - tileHeight + tileHeight/4},
        })
})

ここで使用しているframe関数は、指定したフレームで使用するサブテクスチャを返す関数です。
第2引数で渡したdは、1つのサブテクスチャを表示する表示する時間で、小さくすると素早くサブテクスチャが切り替わります。
"flap"している時のアニメーションは他とくらべて早いことが分かります。

// frame returns the frame for the given time t
// when each frame is displayed for duration d.
func frame(t, d clock.Time, frames ...int) int {
        total := int(d) * len(frames)
        return frames[(int(t)%total)/int(d)]
}

動かしてみます。

STEP24の実行結果

Gopherがアニメーションして、非常に可愛らしくなっています。

STEP 25: Make the gopher spin and zoom when it dies.

対応するコミット:7936d213595bd27b7c0ff8e1411627b4eacf5c91

横スクロールの古き良き死に方でも良いですが、もう少し派手にしましょう。
回転しながらズームさせてみます。

animateDeadGopherで死んだ時のアニメーションを定義しています。
f32.Affine型のポインタを受取り、時刻によって少しずつ回転させたり拡大させていることが分かります。

func animateDeadGopher(a *f32.Affine, t clock.Time) {
        dt := float32(t)
        a.Scale(a, 1+dt/20, 1+dt/20)
        a.Translate(a, 0.5, 0.5)
        a.Rotate(a, dt/math.Pi/-8)
        a.Translate(a, -0.5, -0.5)
}

さて、実行してみましょう。

STEP25の実行結果

死に様が派手になりましたね。
これでよりゲームっぽくなったかなと思います。

Androidで実行する

さぁ"Flappy Gopher"が出来上がりました。
gomobileコマンドでapkを作り、Androidにインストールしましょう。

$gomobile install github.com/adg/game

さっそく動かしてみましょう。

Android上での実行結果

おぉ楽しいですね。結構中毒性があります。

まとめ

この記事では、Go Conference 2015 Winterのキーノート "x/mobile gaming"を1コミットずつ解説していきました。
Go MobileはまだまだExperimentalな技術ですが、今後が楽しみな技術です。
実際にゲームが動くところをみると何か作りたくなりますね。

明日の担当は、yasui-sさんの「同時に実行するということ」です。

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。

おすすめ

合わせて読みたい

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。