Help us understand the problem. What is going on with this article?

TUI版インベーダーゲームをGo言語で作る

この記事は千 Advent Calendar 2019の4日目の記事です。

はじめに

GoのTUIツールに興味が沸いたので、いろいろ調べている時にブロック崩しを見つけました。
これがシンプルな作りで理解しやすかったので、参考にさせてもらって別のゲームを作ってみた!
という記事です。
termbox-goというライブラリの解説はブロック崩しを見てもらった方がいいです ^^;
注)インベーダーゲームってタイトルにしてますがルールは全く違うもどきです。
  ちゃんと再現したGo実装は、こことか見た方がいいです ∑(゚Д゚) 完成度すごい

どんなゲーム?

特徴は、統率がとれていない&武器がないので体当たりしかできない侵略者w、を撃ち落とす
インベーダーゲームです
↓こんな感じです

fvahq-pmxqk.gif

  • ルール
    • インベーダーに3回当たるとゲームオーバー
      • 体当たりしてくるので十字キー(←,→)で避けてください
    • インベーダーに3発当てると撃墜
      • スペースキーで弾を発射
      • 1発当たる度にインベーダーの色が変わります
      • 弾を連続で当てるとコンボポイントでスコアが伸びます
    • 全てのインベーダーを撃墜すればゲームクリア
    • ESCキーでゲーム終了

環境

開発環境

  • Go v1.13
  • macOS
    • Terminal.appよりiTerm2(&標準フォントMonaco)の方がキレイに表示されます

動作環境

  • OSはmacOS,Linux,Windowsで確認済み
  • 等幅フォント
    • Windowsは、標準設定のコマンドプロンプトだと表示崩れます。
      いくつかの等幅フォント試したけどやっぱり崩れます(誰か崩れない設定探してw)
      Windows10で表示が崩れないことを確認しました!
      (@mattn さんにPR頂きました。ありがとうございます!)

実行方法

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

ブロック崩し...フェーズ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

フェーズ1...2差分

一定周期で動作させるためのタイマーをSleepからTickerに変更

複数インベーダーをgoroutineで登場させてみました↓ うじゃうじゃいるw
1ujbm-tiwg6.gif

ブロック崩しはボールの移動を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

フェーズ2...3差分

インベーダー同士の衝突で跳ね返る

インベーダーオブジェクトを配列で持たせて、個々のインベーダーを配列のインデックスで識別するようにしました。
衝突判定関数に配列のインデックスを渡し、そのインベーダーと座標が重なった物体(壁、他のインベーダー、戦闘機)
がないかをチェックし、あれば方向を反転させます。

9fsql-t60ax.gif

差分
 //衝突判定
-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

フェーズ3...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

フェーズ4...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(完成)

フェーズ5...6差分

ゲームとして整えた

  • 画面上部にスコアやライフを表示してゲームっぽく
    • おまけで、goroutine数をリアルタイムで表示するNumGoroutine欄をつけました
  • インベーダーは3回命中で撃墜に変更(難易度・ゲーム性UP

おわりに

TUIでも動きのあるアプリが簡単に作れるtermbox-go、使いやすい!!と思いました。
また、非同期でタイマー/キーイベントで動いている物体(インベーダー、弾丸,戦闘機)同士の衝突判定や処理が
簡単にできるのも並列処理を書きやすいGoだからこそだな、と実感しました。

最後に、実はこのゲームにはバグがあって簡単に最高得点をとることができます。
興味があれば探してみてください!

miyaz
ハマったことをメモっていきます。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away