Goを使ってCLIにフラッシュ画を描画する

この記事は[tomowarkar ひとりAdvent Calendar 2019](https://qiita.com/advent-calendar/2019/tomowarkar)の5日目の記事です。

今日は少し前に作ったCLIへのフラッシュ画像の描画について書いていきます。
# はじめに

![gogo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235435/235bf5e6-9dac-ca52-924c-495847ddec11.gif)
こんな感じで一定時間スパンでn*m行のフィールドにランダムに⚪️🔵🔴を描画しています。


- 時間ループ、キーアクションでGo Channelを使用
- 描画範囲を構造体で定義

と少し発展的なGoの仕組みを学ぶのに役立ちました。

### 注意
<a href="https://github.com/nsf/termbox-go"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/nsf/termbox-go.png" width="460px"></a>
今回こちらのライブラリを使用させていただきましたが[README](https://github.com/nsf/termbox-go)にもあるように、すでに運用が終了しているライブラリです。(2019年12月5日現在)
使用の際はご注意ください。

なお、このライブラリでは代替案として以下のライブラリが挙げられています。
<a href="https://github.com/gdamore/tcell"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/gdamore/tcell.png" width="460px"></a>


# コード全文
コード全文は少し長くなるので折りたたんでおいておきます。
また、今回参考としてライブラリのデモを参考にしました。
参考: https://github.com/nsf/termbox-go/tree/master/_demos

<details><summary>コード全文<br><br><br><br>(クリックしてください)</summary><div>

```flash.go
package main

import (
	"math/rand"
	"time"

	"github.com/nsf/termbox-go"
)

const coldef = termbox.ColorDefault

// Maze ...
type Maze struct {
	width  int
	height int
	field  [][]int
}

// InitMaze ...
func (m *Maze) InitMaze(h, w int) {
	m.width = w
	m.height = h
	m.field = make([][]int, h)
	for i := 0; i < h; i++ {
		m.field[i] = make([]int, w)
	}
}

// RandMaze ...
func (m *Maze) RandMaze() {
	for j := 0; j < m.height; j++ {
		for i := 0; i < m.width; i++ {
			m.field[j][i] = rand.Intn(3)
		}
	}
}

// DrawField ...
func (m Maze) DrawField() {
	termbox.Clear(coldef, coldef)

	for j := 0; j < m.height; j++ {
		for i := 0; i < m.width; i++ {
			if m.field[j][i] == 0 {
				termbox.SetCell(i*2, j, '⚪', coldef, coldef)
			} else if m.field[j][i] == 1 {
				termbox.SetCell(i*2, j, '🔵', coldef, coldef)
			} else {
				termbox.SetCell(i*2, j, '🔴', coldef, coldef)
			}
		}
	}
	termbox.Flush()
}

//key event
func keyEventLoop(kch chan termbox.Key) {
	for {
		switch ev := termbox.PollEvent(); ev.Type {
		case termbox.EventKey:
			kch <- ev.Key
		default:
		}
	}
}

//time event
func timeEventLoop(tch chan bool, span int) {
	for {
		tch <- true
		time.Sleep(time.Duration(span) * time.Millisecond)
	}
}

func mainLoop(mz Maze, tch chan bool, kch chan termbox.Key) {
	for {
		select {
		case key := <-kch: //key event
			switch key {
			case termbox.KeyEsc, termbox.KeyCtrlC: //end event
				return
			}
		case <-tch: //time event
			mz.RandMaze()
			mz.DrawField()
			break
		default:
		}
	}
}

func main() {
	err := termbox.Init()
	if err != nil {
		panic(err)
	}
	defer termbox.Close()

	rand.Seed(time.Now().UnixNano())

	var maze Maze
	maze.InitMaze(15, 15)

	kch := make(chan termbox.Key)
	tch := make(chan bool)
	go keyEventLoop(kch)
	go timeEventLoop(tch, 500)

	mainLoop(maze, tch, kch)
}
```
</div></details>
# コード詳細

### Step0. ライブラリを使うためのおまじない
ライブラリを使用するためのおまじないです。詳細は[こちら](https://github.com/nsf/termbox-go)をどうぞ。

Go で `"rand"` を使うときは `Seed`を設定しろってどこかで見た気がするので(忘れた)設定。
確か初期値が決まっていて厳密に乱数ではないからだった気がする。

```step0.go
package main

import (
	"math/rand"
	"time"

	"github.com/nsf/termbox-go"
)

const coldef = termbox.ColorDefault

func main() {
	err := termbox.Init()
	if err != nil {
		panic(err)
	}
	defer termbox.Close()

	rand.Seed(time.Now().UnixNano())
}
```
### Step1. CLIに描画する構造体を定義
- N×M行列を描画しようと思うので、構造体`Maze`を定義。(※なんでMazeやねんとか言わないで🥺)
- `Maze`は描画するフィールド情報と、幅、高さの情報を持たせる。
- また同時に`Maze`を初期化してN×M行列を作る`InitMaze`とランダムに`Maze`のフィールド情報を更新する`RandMaze`も作成。

今回のフィールド情報は⚪️🔵🔴の3つの情報をもち、それぞれ0,1,2でフィールド情報として持たせるので`rand.Intn(3)`としています。

この辺りは直接数字を打ち込むのではなく、外で定義してから変数を入れるほうが良さそうですね(書きながら反省するスタイル)

``` step1.go
// Maze ...
type Maze struct {
	width  int
	height int
	field  [][]int
}

// InitMaze ...
func (m *Maze) InitMaze(h, w int) {
	m.width = w
	m.height = h
	m.field = make([][]int, h)
	for i := 0; i < h; i++ {
		m.field[i] = make([]int, w)
	}
}

// RandMaze ...
func (m *Maze) RandMaze() {
	for j := 0; j < m.height; j++ {
		for i := 0; i < m.width; i++ {
			m.field[j][i] = rand.Intn(3)
		}
	}
}
```
### Step2. CLIに構造体を描画

次に描画する関数`DrawField`を書いていきます。

```step2.go
// DrawField ...
func (m Maze) DrawField() {
	termbox.Clear(coldef, coldef)

	for j := 0; j < m.height; j++ {
		for i := 0; i < m.width; i++ {
			if m.field[j][i] == 0 {
				termbox.SetCell(i*2, j, '⚪', coldef, coldef)
			} else if m.field[j][i] == 1 {
				termbox.SetCell(i*2, j, '🔵', coldef, coldef)
			} else {
				termbox.SetCell(i*2, j, '🔴', coldef, coldef)
			}
		}
	}
	termbox.Flush()
}
```

1. フィールドを初期化
2. フィールド情報の0,1,2をそれぞれ⚪️🔵🔴に変換
3. 描画

この流れです。
しかしこのままではコンマ秒単位で描画されるので、描画されていることを認識することができません。
なので以下のようにしてループを定義して描画の様子を確認してみます。

```tmp.go
func main() {
	err := termbox.Init()
	if err != nil {
		panic(err)
	}
	defer termbox.Close()

	rand.Seed(time.Now().UnixNano())

	var maze Maze
	maze.InitMaze(15, 15)
	for i := 0; i < 10000; i++ {
		maze.DrawField()
	}
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235435/058b09bc-500d-1cb6-ad3e-a55c70e8b42a.png)
このような⚪️の15×15行列が確認できていれば成功です。

### Step3. 描画イベントから抜け出すキーイベントを定義
いちいち描画時間を設定するのはナンセンスなので、描画を無限ループさせ、Escキーによって描画画面から脱出できるようにします。

描画のループとキーイベントは別軸で評価したいため、Go Channelを使用します。

なのでキーイベントを判定するループ`keyEventLoop`とメインの描画のループ`mainLoop`をそれぞれ定義します。

```step3.go
//key event
func keyEventLoop(kch chan termbox.Key) {
	for {
		switch ev := termbox.PollEvent(); ev.Type {
		case termbox.EventKey:
			kch <- ev.Key
		default:
		}
	}
}

func mainLoop(mz Maze, kch chan termbox.Key) {
	for {
		select {
		case key := <-kch: //key event
			switch key {
			case termbox.KeyEsc: //end event
				return
			}
		default:
			mz.RandMaze()
			mz.DrawField()
		}
	}
}

func main() {
	err := termbox.Init()
	if err != nil {
		panic(err)
	}
	defer termbox.Close()

	rand.Seed(time.Now().UnixNano())

	var maze Maze
	maze.InitMaze(15, 15)
	kch := make(chan termbox.Key)
	go keyEventLoop(kch)

	mainLoop(maze, kch)
}
```

1. メインループで描画を更新、描画のループを行う。
2. メインループが回っている間並行してキーイベントのループが回っていて、キーイベントがあった場合`kch`に情報を送る
3. メインループは`kch`からキーイベントの情報を受け取り、キーイベントの情報によってイベントを実行(今回はEscキーでループの脱出)

これでコードを走らせると、すごい勢いで描画が⚪️🔵🔴に更新されていて、かつEscキーを押すことで描画画面から抜け出すことができることがわかります。


### Step4. 時間イベントを定義して一定時間ごとに描画を更新
このままでは描画の更新が早すぎます。
一定時間ごとに描画が更新されるように時間イベント`timeEventLoop`を設定し、メインループ`mainLoop`を更新します

```step4.go
//time event
func timeEventLoop(tch chan bool, span int) {
    for {
        tch <- true
        time.Sleep(time.Duration(span) * time.Millisecond)
    }
}

func mainLoop(mz Maze, tch chan bool, kch chan termbox.Key) {
    for {
        select {
        case key := <-kch: //key event
            switch key {
            case termbox.KeyEsc, termbox.KeyCtrlC: //end event
                return
            }
        case <-tch: //time event
            mz.RandMaze()
            mz.DrawField()
            break
        default:
        }
    }
}

func main() {
    err := termbox.Init()
    if err != nil {
        panic(err)
    }
    defer termbox.Close()

    rand.Seed(time.Now().UnixNano())

    var maze Maze
    maze.InitMaze(15, 15)

    kch := make(chan termbox.Key)
    tch := make(chan bool)
    go keyEventLoop(kch)
    go timeEventLoop(tch, 500)

    mainLoop(maze, tch, kch)
}
```
1. メインループは無限ループを行う。
2. メインループが回っている間並行して時間イベントのループが回っていて任意の時間[ms]ごとに`tch`に情報を送る
3. メインループは`tch`から時間イベントの情報を受け取り、時間イベントが発生して場合において描画の更新、再描画を行う。

という形になりました。

# まとめ
これで、
- 500ms毎に15×15行列がランダムで更新され描画される
- Escキーを押すことで描画画面から脱出

ということができました。
行列の数や描画の更新時間を変えて遊んでみてください。

コードは[Github Gist](https://gist.github.com/tomowarkar/3bb50298393b66148e43e00238e52083)にもおいておきます!

https://gist.github.com/tomowarkar/3bb50298393b66148e43e00238e52083

以上明日も頑張ります！！
[tomowarkar ひとりAdvent Calendar Advent Calendar 2019](https://qiita.com/advent-calendar/2019/tomowarkar)

# 追記
先日(4日目)のアドベントカレンダーで同じくtermbox-goを使った記事を発見したので載せておきます。
[TUI版インベーダーゲームをGo言語で作る](https://qiita.com/miyaz/items/40360bfa51da17957ad8)