この記事は千 Advent Calendar 2019の4日目の記事です。
はじめに
GoのTUIツールに興味が沸いたので、いろいろ調べている時にブロック崩しを見つけました。
これがシンプルな作りで理解しやすかったので、参考にさせてもらって別のゲームを作ってみた!
という記事です。
termbox-goというライブラリの解説はブロック崩しを見てもらった方がいいです ^^;
注)インベーダーゲームってタイトルにしてますがルールは全く違うもどき
です。
ちゃんと再現したGo実装は、こことか見た方がいいです ∑(゚Д゚) 完成度すごい
どんなゲーム?
特徴は、統率がとれていない&武器がないので体当たりしかできない侵略者w、を撃ち落とす
インベーダーゲームです
↓こんな感じです
- ルール
- インベーダーに3回当たるとゲームオーバー
- 体当たりしてくるので十字キー(←,→)で避けてください
- インベーダーに3発当てると撃墜
- スペースキーで弾を発射
- 1発当たる度にインベーダーの色が変わります
- 弾を連続で当てるとコンボポイントでスコアが伸びます
- 全てのインベーダーを撃墜すればゲームクリア
- ESCキーでゲーム終了
- インベーダーに3回当たるとゲームオーバー
環境
開発環境
- Go v1.13
- macOS
- Terminal.appよりiTerm2(&標準フォントMonaco)の方がキレイに表示されます
動作環境
- OSはmacOS,Linux,Windowsで確認済み
- 等幅フォント
実行方法
git clone https://github.com/miyaz/invaders.git
cd invaders
go run main.go
OR
こちらからバイナリダウンロードして実行
開発の思い出
開発過程でこんなんあったなぁ
ということを思い出しながらやったことを説明します。
なお、それぞれのフェーズは下記のように実行できます。
git checkout phase1 # phase1から6まであります
go run main.go
フェーズ1
ブロック要素で作るインベーダー
マルチOS対応を考えるとターミナルで画像を使うのは難しいので、ブロック要素のみを使ってインベーダーを作りました。
termboxでセルを描画するSetCell関数は、1文字ずつ座標(x,y)と文字(rune型)を渡す必要があります。
キャラクター文字列を改行で区切り、rune配列に変換して1文字ずつSetCellで描画しています。
//インベーダーを描画
func drawInvader(x, y int) {
invader := `
▚▄▄▞
▟█▟▙█▙
▘▝▖▗▘▝
`
scanner := bufio.NewScanner(strings.NewReader(invader))
j := 0
for scanner.Scan() {
line := scanner.Text()
runes := []rune(line)
for i := 0; i < len(runes); i++ {
termbox.SetCell(x+i-3, y+j-2, runes[i], termbox.ColorDefault, termbox.ColorDefault)
}
j++
}
}
フェーズ2
一定周期で動作させるためのタイマーをSleepからTickerに変更
複数インベーダーをgoroutineで登場させてみました↓ うじゃうじゃいるw
ブロック崩しはボールの移動をtime.Sleepを使って一定周期で動くように実装されていました。
今回はインベーダを撃ち落とすというゲームにしたかったので、goroutineにchannelで通知してリアルタイムに
処理ができるようにtickerを使ったタイマーにし、for-select-caseを使いました。
//タイマーイベント
-func invaderTimerLoop(itch chan bool) {
+func moveLoop(moveCh chan int, mover, ticker int) {
+ t := time.NewTicker(time.Duration(ticker) * time.Millisecond)
for {
- itch <- true
- time.Sleep(time.Duration(_invaderTimeSpan) * time.Millisecond)
+ select {
+ case <-t.C: //タイマーイベント
+ moveCh <- mover
+ break
+ }
}
+ t.Stop()
}
フェーズ3
インベーダー同士の衝突で跳ね返る
インベーダーオブジェクトを配列で持たせて、個々のインベーダーを配列のインデックスで識別するようにしました。
衝突判定関数に配列のインデックスを渡し、そのインベーダーと座標が重なった物体(壁、他のインベーダー、戦闘機)
がないかをチェックし、あれば方向を反転させます。
//衝突判定
-func checkCollision(st state) state {
+func checkCollision(st state, i int) state {
+ //左の壁
+ if st.Invaders[i].Pos.X <= 0 {
+ st.Invaders[i].Pos.X = 1
+ st.Invaders[i].Vec.X = 1
以降判定処理が続く
フェーズ4
弾を発射してインベーダーを撃墜
スペースキーで戦闘機から弾を発射できるようにしました。
(ちなみに戦闘機のデザインは娘(小4)にやってもらいました!)
弾の発射ごとにgoroutineを生成して、それぞれがtickerで刻み、それをcontroller側でchannel経由で
通知を受け取ってY座標をインクリメントし描画する=前に飛んでいく、という作りです。
(言葉で書くとわかりづらいな)
ここで、撃墜されたインベーダーを消す場合に配列だと扱いづらいのでmapに変更しました。
変更前) []invader
変更後) map[int]*invader
配列(正確にはスライス)の要素を消すのは下記のような関数でやればいいですが、インデックスがズレます(当然
配列インデックスでインベーダーを識別しているとバグになりやすい、というのがmapにする理由です。
func remove(s []invader, i int) []invader {
s = s[:i+copy(s[i:], s[i+1:])]
return s
}
また、mapを使えばキーを指定してGo標準のdelete関数で消せるので簡単。
指定キーに紐づいたインベーダーを削除、ということができるのでコードもシンプルになるし、キーも連番ではなく
自由に作れるのもメリットですね。
フェーズ5
消えた弾丸goroutineをちゃんとクローズする
フェーズ4では、弾をうつほどCPU負荷が増えていきました。
インベーダーの体当たり以外にPCがフリーズするかも、という緊張感も楽しめるゲームに仕上がりました!(違う
原因は簡単で、弾の発射ごとに生成されるgoroutineが、外れる(画面外)or命中して消えた後も動き続けていました。
使い終わったgoroutineは止めてあげる必要があります(お作法
対処としては、弾(bullet)というstructにCloseCh chan bool
を持たせて、生成したgoroutineにこのCloseChを
渡してcloseしたことを外から通知できるようにしました。
弾が外れる(画面外)or命中して消える際にgoroutineを止めるには、close(CloseCh)
を呼び出せばいいだけです。
//タイマーイベント
-func moveLoop(moveCh chan int, mover, ticker int) {
+func moveLoop(moveCh chan int, closeCh chan bool, mover, ticker int) {
t := time.NewTicker(time.Duration(ticker) * time.Millisecond)
+ defer t.Stop()
for {
select {
case <-t.C: //タイマーイベント
moveCh <- mover
break
+ case <-closeCh:
+ return
}
}
- t.Stop()
}
フェーズ6(完成)
ゲームとして整えた
- 画面上部にスコアやライフを表示してゲームっぽく
- おまけで、goroutine数をリアルタイムで表示するNumGoroutine欄をつけました
- インベーダーは3回命中で撃墜に変更(難易度・ゲーム性UP
おわりに
TUIでも動きのあるアプリが簡単に作れるtermbox-go、使いやすい!!と思いました。
また、非同期でタイマー/キーイベントで動いている物体(インベーダー、弾丸,戦闘機)同士の衝突判定や処理が
簡単にできるのも並列処理を書きやすいGoだからこそだな、と実感しました。
最後に、実はこのゲームにはバグがあって簡単に最高得点をとることができます。
興味があれば探してみてください!