このエントリーは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で動作確認しています。
しかし、他の環境でも動作するようですので、ぜひ試してみてください。
まずは準備を行っていきたいと思います。
このキーノートは、Andrewさんが作ったdtというツールを使って、1コミットずつ差分を確認しながら実行していくものでした。
最終的には、"Flappy Gopher"というゲームができあがります。
1ステップずつ徐々にゲームが完成していく様子を見ながら、Go Mobileの基礎的な部分を理解できるという、とても良いプレゼンでした。
また、ゲームを作った事のない方にとっては、ゲームの作り方も学べる興味深いものだったかと思います。
まずは、dtをインストールしましょう。
$ go get github.com/adg/dt
$GOPATH/bin
にPATH
が通されていれば、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ステップずつ作っていきましょう。
対応するコミット:4a9e64a5e0ae04c9535d3f760e25de93f8eeef87
以下のように、dt
に最後のコミットから1つ前(最後のコミットはREADMEの追加のため)のコミット番号を渡します。
$ dt 7936d2
そうすると、プロンプトが出てきます。
選べる選択しは、q
、r
、n
と数字です。
q
はdt
の終了、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
空のウィンドウですね。
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の画像などになります。
対応するコミット:43ef6bb2400cff8bbf84d1c4168e7fa7244d19a7
このステップでは、onStart
、onStop
、onPaint
の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
関数では、スプライトエンジンの初期化を行っています。
Go Mobileでは、2Dのスプライトエンジンを提供しています。
スプライトエンジンを使えば、簡単に2Dのシーングラフを構築することができます。
sprite
パッケージでは、スプライトエンジンを表すEngine
インタフェースを提供しています。
具体的な実装はOpenGL以外にも取れるように別のパッケージに任せてあります。
OpenGLの実装は、sprite/glsprite
にあります。
なお、スプライトエンジンの詳細についても、こちらの記事で説明してあります。
onStart
関数では、以下の手順でスプライトエンジンの初期化を行っています。
Images
オブジェクトの作成Images
オブジェクトは、OpenGLのテクスチャを作るためのオブジェクトです。
内部にシェーダーなどを持ちテクスチャを生成するために使います。
sprite
パッケージを使って2Dのシーンを描画するためには、シーングラフを構築していきます。
ここでは、シーングラフのルートノード(scene
)を作成し、エンジンオブジェクトに登録しています。
シーングラフのノードには、アフィン変換行列を設定することでノードに設定したテクスチャがどの位置にどのような大きさや角度で描画されるのか決めます。
最終的なテクスチャの描画位置などは、ルートからそのノードまでのアフィン変換行列を掛けあわせた結果が用いられます。
onStop
では、エンジンオブジェクトとテクスチャを破棄しています。
onPaint
では、背景を白く塗りつぶし、シーンのレンダリングを行っています。
Render
メソッドに渡しているnow
という変数は、現在のフレームを表しています。
この場合は、60FPS
で計算されています。
sz
については後ほど説明します。
このステップでは、各イベントをハンドリングするコードも追加されています。
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
は、画面の大きさが変わった場合や画面を回転させた場合に発生するイベントです。
アプリ起動時に1度はpaint.Event
が発生する前に必ず呼ばれます。
sz
は、サイズ情報が格納されたもので、シーングラフを描画する際に用いられます。
case size.Event:
sz = e
paint.Event
は描画イベントです。
ここでは、OpenGLのコンテキストがなかったりOSレイヤーから送られてきた描画イベントは無視しています。
a.Send(paint.Event{})
のように、自前で発生させた描画イベントの場合は、e.External
がfalse
になります。
onPaint
メソッドを呼び出すことでシーングラフを構築し描画を行っています。
最終的に画面に出力するためには、Publish
メソッドを呼び出す必要があります。
描画が一通り終わったら、再度描画イベントを走らせて次のフレームに移ります。
case paint.Event:
if glctx == nil || e.External {
continue
}
onPaint(glctx, sz)
a.Publish()
a.Send(paint.Event{}) // keep animating
それではこのステップの実行結果を見てみましょう。
背景が白くなりましたね。
対応するコミット:0cf230e77f624ee96a0e87dd482e8c35b282f3e1
game.go
が追加されています。
このファイルには、ゲームロジックが記述されるGame
型が構造体として宣言されています。
ここではまだ、Game
構造体は新しいシーングラフを作成するScene
メソッドしか持っていません。
シーングラフの初期化は、onStart
から移動されています。
実行してみましょう。
当然ながら描画部分をいじってないので何も変わりません。
対応するコミット:99aa823b17a8a4f6d2a63ea58bc2023ab86192b5
game.go
にloadTextures
という関数が追加されています。
この関数は画像ファイルからテクスチャを読み込み、さらにサブテクスチャに分けて返します。
画像の読み込みは、asset.Open
関数を使います。
os.Open
と似たような関数ですが、モバイルアプリの場合、OSによってアプリごとに割り当てられるファイル領域が異なります。
その差分を吸収するためにasset.Open
を使います。
もちろん、正しいパスを渡せばos.Open
も使えます。
asset.Open
は、main
パッケージがあるディレクトリ直下のassets
というディレクトリの中からファイルを探します。
この場合は、STEP1で追加したplaceholder-sprites.png
という画像を開いています。
asset.Open
の返すasset.File
インタフェースは、Read
メソッドを持っているためimage
パッケージのデコーダにそのまま渡すことができます。
ここでは、image.Decode
でimage.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)},
}
}
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
}
それでは実行してみましょう。
まだGopherではないですが、青い画像が描画されましたね。
対応するコミット: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
}
}
実行してみましょう。
紫色の地表が描画されましたね。
対応するコミット: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.go
のonPaint
内で呼ばれています。
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)
}
このステップでは、前のステップと実行結果は変わらないので省略します。
対応するコミット: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ファイルを貼っています。
地表を表す紫色のタイルが右から左へとスクロールしている事が分かると思います。
対応するコミット: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になるはずの青色のタイルは浮いたままですね。
対応するコミット:491933c77eb328edcc34b7594691ff7edf01f65f
横スクロールするようになったはいいですが、スクロールがガタガタで気になります。
そこでこのステップでは、スクロールをもっとスムーズになるように処理を追加しています。
新しく以下の2つの定数が追加されています。
初速度と加速度です。スクロールがだんだんと加速していくことが予想できます。
const (
...
initScrollV = 1 // initial scroll velocity
scrollA = 0.001 // scroll accelleration
...
)
スムーズなスクロールを実現するために、Game
構造体にscroll
フィールドを追加してます。
scroll
フィールドは、x
とv
というフィールドを持つ構造体です。
読者の方の中には初めて見る書き方かもしれませんが、単純に型を型リテラルで書いているだけです。
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.v
にscrollA
を足し、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()
}
}
実行してみましょう。
先ほどよりスクロールがスムーズになっていることが分かるかと思います。
放っておくと、スクロールがどんどん加速していきます。
対応するコミット: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
}
動かしてみましょう。
あっというまにGopherが落ちていきました。
対応するコミット: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
}
}
対応するコミット:bbe69ef8e8874637dc48333c19afb3de30c57a22
ここまでGopherはただ横に走るだけでジャンプできませんでした。
このステップではジャンプするようにタッチイベントとキーイベントをハンドリングします。
タッチイベントは、touch.Event
として送られてきます。
イベントオブジェクトのType
フィールドに画面に触れたのか離したのかを取ることができます。
触れた場合は、game.Press
メソッドにtrue
を渡し、離した場合はgame.Press
メソッドにfalse
を渡しています。
キーイベントは、key.Event
として送られてきます。
イベントオブジェクトのCode
に押されたキーのコードが送られてきます。
この場合は、スペースキー以外は無視しています。
キーイベントの方もDirection
で押したのか離したのかを取得し、押した場合はtrue
を離した場合はfalse
をgame.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
メソッドが追加されています。
down
がtrue
の場合は速度にjumpV
が設定されます。
jumpV
は新しく定義された定数で、マイナスの値です。
down
がfalse
の場合は、飛んでいる最中(マイナスの値)の場合は速度を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
}
}
}
それでは、実行してみましょう。
スペースキーを押すとGopherが飛びます!
しかし、連打するとどこか遠くに行ってしまいます。。
対応するコミット: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
}
このatRest
がtrue
の場合は、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
}
}
実行結果を見てみましょう。
gifで見ても分かりづらいですが、地面に触れている時しかジャンプしてないことが分かると思います。
gifでは伝わらないですが、実はスペースを連打しています。
対応するコミット:d16bc6c27d061e5fc43dbe36e7441bc50b77da2b
ここまでのGopherはかなり頑丈で壁にぶつかっても死にません。
しかし、このままではゲームにならないので、ぶつかったら死ぬようにしましょう。
gopher
にdead
というフィールドを追加し、生きている時は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
の中で使われているgopherCrashed
とkillGopher
は新しく追加されたメソッドです。
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が死んでしまうようになりました。
実行して見てみましょう。
うまくジャンプして壁を超えた場合は死なずにすみますが、壁にぶつかるとGopherが赤くなって死んでしまうことが分かります。
対応するコミット: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
}
...
さて、これで横スクロールゲームの雰囲気が出るようになりました。
動かしてみましょう。
雰囲気が出てきました。
対応するコミット: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
}
...
実行してみましょう。
ゆっくりスクロールが止まっていることが分かります。
対応するコミット: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()
}
...
動かしてみましょう。
死んだ後に一定時間経つとリセットされていることが分かります。
対応するコミット: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
flapped
もclampToGround
で地面に触れた時にfalse
に設定されます。
"flap"している間も画像を変えたいため、別途サブテクスチャを設定しています。
使用するサブテクスチャは、スプライト画像の左から3番目の緑色の部分です。
texGopherFlap: sprite.SubTex{t, image.Rect(1+n*2, 0, n*3-1, n)},
実行してみましょう。
ジャンプした後にちょっとだけ上に上がることができるはずです。
対応するコミット: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
}
実行してみましょう。
おっと小さな段差でGopherが死んでしまいましたね。
対応するコミット: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
}
実行してみましょう。
ちょっとの段差では、死ななくなりましたね。
対応するコミット:e1b09ed76e81b2fc5c75c1a02de3ec48e8e8a3f6
青い四角に愛着が湧いてきたところだと思いますが、せっかくなので、Renee Frenchさんの描いた可愛らしいGopherに変えましょう。
一気に本格的なゲームになってきました。
Gopherの背景が透過処理されていないのが気になりますね。
onPaint
の先頭に3行を追加すると透過処理がされると思います。
glctx.Enable(gl.BLEND)
glctx.BlendEquation(gl.FUNC_ADD)
glctx.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
対応するコミット: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},
})
})
動かしてみましょう。
見やすくなりましたね。
対応するコミット: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)
}
実行してみましょう。
あまり変化はありませんが、少し自然になったかなと思います。
対応するコミット: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)]
}
動かしてみます。
Gopherがアニメーションして、非常に可愛らしくなっています。
対応するコミット: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)
}
さて、実行してみましょう。
死に様が派手になりましたね。
これでよりゲームっぽくなったかなと思います。
さぁ"Flappy Gopher"が出来上がりました。
gomobile
コマンドでapkを作り、Androidにインストールしましょう。
$gomobile install github.com/adg/game
さっそく動かしてみましょう。
おぉ楽しいですね。結構中毒性があります。
この記事では、Go Conference 2015 Winterのキーノート "x/mobile gaming"を1コミットずつ解説していきました。
Go MobileはまだまだExperimentalな技術ですが、今後が楽しみな技術です。
実際にゲームが動くところをみると何か作りたくなりますね。
明日の担当は、yasui-s
さんの「同時に実行するということ」です。
KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。
合わせて読みたい
KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。