Go & TermDash で リッチな端末アプリを作ってみよう #1

はじめに はじめまして。ウナルステクノロジー札幌支社の嶌です。 本連載

はじめに

はじめまして。ウナルステクノロジー札幌支社の嶌です。

本連載では、数回の記事で、Linux環境でのGo開発環境の構築から始め、
Go + TermDashで端末ベースのゲーム(のようなもの)の開発を通じて、
Goとその特長であるGoroutineについて学んでいきたいと思います。

さて、端末上で動作するゲームの代表作といえば、
「不思議のダンジョン」シリーズの原型であるローグライクが有名ですが、
今回はGoroutineでリアルタイム性をもたせ、
(CUIとしては)グラフィカルなものを作ってみます。

具体的には、「ターミナル上の計器を見ながら、
レバーやボタンを使って乗り物を操縦する」といったものになります。
それにあたって今回使うのは “TermDash” というライブラリです。

TermDashとは

TermDash (https://github.com/mum4k/termdash) とは、
端末上に様々なUIを設置できるGo言語のライブラリの一つで、
ncursesのような半角文字単位ではなく、
点字をピクセルに見立てた解像度の高い図形を描画できるのが特長です。

Go開発環境の構築

筆者が実際に構築を行った環境は Ubuntu 18.04 と Linux Mint 19.1 です。

Linux環境では、yumやaptなど、
ディストリビューション毎のパッケージマネージャがありますが、
それだと古いものしか入らないため、今回はsnapを利用します。

snapのインストール

snapは、ひとことで説明すると、
「ディストリビューション非依存のパッケージマネージャ」であり、
Linuxのコンテナ技術によって実行環境を丸ごと閉じ込める形式で
インストールが可能です。
他にもたくさんのメリットがあるので、興味のある方は
https://snapcraft.io/blog/8-ways-snaps-are-different
を一読してみてください。

snapの導入自体はかんたんに行なえます。
ubuntu系のディストリビューションでは、
以下のコマンド一つでインストールされます。

$ sudo apt install snapd

インストールされたsnap(snapd)のバージョンを確認してみましょう。

$ snap version

snap       2.40+18.04
snapd      2.40+18.04
series     16
linuxmint  19.1
kernel     4.15.0-70-generic

Goのインストール

snap install “パッケージ名”でインストールできますが、
一応、指定するパッケージ名を以下コマンドで確認してみましょう。

$ snap find golang

Name                       Version            Publisher                 Notes    Summary
go                         1.13.4             mwhudson                  classic  Go programming language compiler, linker, stdlib
goland                     2019.2.4           jetbrains*                classic  A Clever IDE to Go
go-example-webserver       16.04-9            canonical*                -        Minimal Golang webserver for snappy
test-snapd-go-webserver    16.04-9            canonical*                -        Minimal Golang webserver for snappy
gogs                       0.1                vtuson                    -        golang based git server and ui

...

一番上にある go を指定すればよいとわかったので、次は実際にインストールを行います。

$ sudo snap install go --classic

–classicオプションについては、以下をご覧ください。
https://snapcraft.io/docs/snap-confinement
かんたんに説明すると、コンテナでシステムから隔離せずに、
“昔ながらの”方法でのインストールするという意味合いです。

続けて、go get コマンドでTermdashをインストールしてみましょう。

$ go get -u github.com/mum4k/termdash
$ go get -u github.com/nsf/termbox-go #依存パッケージ

うまく行くと、 ~/go 以下はこのようなファイル構成となります。
(treeコマンドは便利なので入れておくことをおすすめします)

$ tree -L 3
.
├── pkg
│   └── linux_amd64
│       └── github.com/
└── src
    └── github.com
        ├── mattn/
        ├── mum4k/
        └── nsf/

これで必要となるライブラリはすべて入ったので、最後にうまく動くかどうか試すためボタンウィジェットのデモを実行してみましょう。

go run ~/go/src/github.com/mum4k/termdash/widgets/button/buttondemo/buttondemo.go

導入に成功していれば、下のような画面になります。

お疲れ様でした!
これでようやく、開発のスタートラインに立つことができました。

今回作りたいもの

早速、プログラミングの方に入っていきましょう。
最終的にはある程度ゲームらしいものにしたいですが、少しずつ作っていきます。
乗り物を操縦する上で絶対に欠かせないものは
アクセル(みなさんもそう思いますよね?)ということで、
今回作るものは、以下のようなものです。
若干複雑ですが、UIがシンプルであるぶん
挙動をリアルにさせて想像力に訴える作戦です。

* タービンエンジンが1基ある
* プラス/マイナスボタンで、回転数の”設定値”を制御できる
* 時間経過とともに、実際の回転数が”設定値”に近づいていく
* 回転数に基づいて、緩やかに速度が変化する。
* 高回転/高速であるほど、速度は上がりにくく、下がりやすい

ソースコード全体

いきなり全体を載せてしまいますが、後ほど個別に解説したいと思います。

package main

import (
	"context"
	"fmt"
	"math"
	"math/rand"
	"time"

	"github.com/mum4k/termdash"
	"github.com/mum4k/termdash/align"
	"github.com/mum4k/termdash/cell"
	"github.com/mum4k/termdash/container"
	"github.com/mum4k/termdash/linestyle"
	"github.com/mum4k/termdash/terminal/termbox"
	"github.com/mum4k/termdash/terminal/terminalapi"
	"github.com/mum4k/termdash/widgets/button"
	"github.com/mum4k/termdash/widgets/segmentdisplay"
	"github.com/mum4k/termdash/widgets/donut"
	"github.com/mum4k/termdash/widgets/gauge"
)


// プレイヤーデータ
type Player struct {

	// タービン回転数: 0 ~ 200
	// 設定値
	turbineRpmSettingValue float64

	// 実測値
	turbineRpmActualValue float64

	// 現在の速度
	velocity float64

	// 加速度
	acceleration float64
}

var debug bool = true

func debugLog(message string) {
	if debug {
		fmt.Println(message)
	}
}


func updateTick(ctx context.Context, p *Player, delay time.Duration) {
	ticker := time.NewTicker(delay)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			// 速度の更新 --------------------------------------------------------------------------------
			// 回転数の計算
			p.turbineRpmActualValue += (p.turbineRpmSettingValue - p.turbineRpmActualValue) / ((p.turbineRpmActualValue + 1) * 5)
			p.turbineRpmActualValue *= 0.998
			p.turbineRpmActualValue += p.turbineRpmActualValue * rand.Float64() * 0.004

			// 加速度の計算
			p.acceleration = float64(p.turbineRpmActualValue / 10.0)

			// 速度の計算
			p.velocity += p.acceleration / 10
			p.velocity *= 0.99 + rand.Float64()*0.003 // 減速係数

		case <-ctx.Done():
			return
		}
	}
}


// 速度計
func speedometer(ctx context.Context, p *Player, display *segmentdisplay.SegmentDisplay, delay time.Duration) {
	ticker := time.NewTicker(delay)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			if err := display.Write([]*segmentdisplay.TextChunk{
				segmentdisplay.NewChunk(fmt.Sprintf("%06.1f", p.velocity)),
			}); err != nil {
				panic(err)
			}
		case <-ctx.Done():
			return
		}
	}
}


// タービン回転数設定値ゲージ
func rpmSettingGauge(ctx context.Context, p *Player, g *gauge.Gauge, delay time.Duration) {
	ticker := time.NewTicker(delay)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			displayValue := int(math.Max(math.Min(float64(p.turbineRpmSettingValue), 200.0), 0))
			if err := g.Absolute(displayValue, 200); err != nil {
				panic(err)
			}
		case <-ctx.Done():
			return
		}
	}
}


// タービン回転数ゲージ
func rpmMeterDonut(ctx context.Context, p *Player, d *donut.Donut, delay time.Duration) {
	ticker := time.NewTicker(delay)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			displayValue := math.Max(math.Min(float64(p.turbineRpmActualValue), 200.0), 0)

			if displayValue < 140 {
				if err := d.Absolute(int(displayValue), 200, donut.CellOpts(cell.FgColor(cell.ColorYellow))); err != nil {
					panic(err)
				}
			} else {
				if err := d.Absolute(int(displayValue), 200, donut.CellOpts(cell.FgColor(cell.ColorRed))); err != nil {
					panic(err)
				}
			}

		case <-ctx.Done():
			return
		}
	}
}


func main() {
	// プレイヤーの状態初期化
	player := Player{
		turbineRpmSettingValue: 0,
		turbineRpmActualValue:  0,
		velocity:               0.0,
		acceleration:           0.0,
	}


	t, err := termbox.New()
	if err != nil {
		panic(err)
	}
	defer t.Close()

	ctx, cancel := context.WithCancel(context.Background())

	// セグメントディスプレイ: 速度計
	display, err := segmentdisplay.New()
	if err != nil {
		panic(err)
	}


	// ボタン: 設定値をプラス
	buttonTurbinePlus, err := button.New("+ 10", func() error {
		// process
		player.turbineRpmSettingValue += 10
		if player.turbineRpmSettingValue > 200 {
			player.turbineRpmSettingValue = 200
		}
		return nil
	})
	if err != nil {
		panic(err)
	}


	// ボタン: 設定値をマイナス
	buttonTurbineMinus, err := button.New("- 10", func() error {
		// process
		player.turbineRpmSettingValue -= 10
		if player.turbineRpmSettingValue < 0 {
			player.turbineRpmSettingValue = 0
		}
		return nil
	})
	if err != nil {
		panic(err)
	}


	// 円グラフ: rpm
	rpmMeter, err := donut.New(
		donut.CellOpts(cell.FgColor(cell.ColorYellow)),
		donut.HolePercent(50),
		donut.ShowTextProgress(),
		donut.Label("turbine rpm", cell.FgColor(cell.ColorYellow)),
	)
	if err != nil {
		panic(err)
	}


	// 棒グラフ: 設定出力
	rpmSettingMeter, err := gauge.New(
		gauge.Height(1),
		gauge.Border(linestyle.Light),
		gauge.BorderTitle("Setting Value"),
	)
	if err != nil {
		panic(err)
	}


	// goroutine作成
	go rpmMeterDonut(ctx, &player, rpmMeter, 100*time.Millisecond)
	go rpmSettingGauge(ctx, &player, rpmSettingMeter, 250*time.Millisecond)
	go speedometer(ctx, &player, display, 16*time.Millisecond)
	go updateTick(ctx, &player, 16*time.Millisecond)


	// Layout ----------------------------------------------------------------------
	c, err := container.New(
		t,
		container.Border(linestyle.Light),
		container.BorderTitle("PRESS Q TO QUIT"),
		container.SplitHorizontal(
			container.Top(
				container.Border(linestyle.Light),
				container.BorderTitle("Current Speed: (kt)"),
				container.PlaceWidget(display),
			),
			container.Bottom(
				container.Border(linestyle.Light),
				container.BorderTitle("Turbine Control"),
				container.SplitVertical(
					container.Left(
						container.SplitHorizontal(
							container.Top(
								container.PlaceWidget(rpmSettingMeter),
							),
							container.Bottom(
								container.SplitVertical(
									container.Left(
										container.PlaceWidget(buttonTurbinePlus),
										container.AlignHorizontal(align.HorizontalCenter),
									),
									container.Right(
										container.PlaceWidget(buttonTurbineMinus),
										container.AlignHorizontal(align.HorizontalCenter),
									),
								),
							),
						),
					),
					container.Right(
						container.Border(linestyle.Light),
						container.BorderTitle("rpm"),
						container.PlaceWidget(rpmMeter),
					),
				),
			),
		),
	)
	if err != nil {
		panic(err)
	}


	// 'Q' キーでプログラム終了
	quitter := func(k *terminalapi.Keyboard) {
		if k.Key == 'q' || k.Key == 'Q' {
			cancel()
		}
	}


	if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(16*time.Millisecond)); err != nil {
		panic(err)
	}

	debugLog("main(): end")
}

プレイヤーデータを表す構造体

自機の状態を保持するためにPlayer構造体を定義します。今回は加減速に関する値のみ定義していきます。

// プレイヤーデータ
type Player struct {

	// タービン回転数: 0 ~ 200
	// 設定値
	turbineRpmSettingValue float64

	// 実測値
	turbineRpmActualValue float64

	// 現在の速度
	velocity float64

	// 加速度
	acceleration float64
}

時間経過を管理する処理

updateTick()は、Goroutineで繰り返し実行されます。
その中で、プレイヤーの様々なパラメータを加速度や抵抗などによって刻々と変化させています。
このメソッドの実行間隔がそのまま時間経過の速度となります。

引数については、以下のような意味合いになります。

・ctx : Goroutine終了のタイミングを管理するために渡しています。
・p : プレイヤーの構造体です。
・delay : この値で設定した間隔ごとにプレイヤーの値を更新します。言い換えると、小さいほど時間の経過が速くなります。

処理の流れとしては、forループ中の case<-ticker.C: 以降の処理が、delay引数で渡した間隔で実行されます。ここを16ミリ秒に設定すると一般的なゲームの1フレームと大体同じ長さになります。

本来であれば、ここでTickerやチャネルについてきちんとした説明をするべきですが、
長くなってしまうのと、正しく説明する自信がまだないので、改めて別記事にて
行いたいと思います。この記事の目的はあくまで、TermDashのかっこよさを
知ってもらうことです!

そして、p(プレイヤー)の状態の移り変わりにリアリティを持たせるために、
色々と計算式を入れています。
rand.Float64() … が隠し味で、動力と抵抗がせめぎ合ってる感じが出ているかと思います。

func updateTick(ctx context.Context, p *Player, display *segmentdisplay.SegmentDisplay, delay time.Duration) {
	ticker := time.NewTicker(delay)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:

			// 速度の更新 --------------------------------------------------------------------------------
			// 回転数の計算
			p.turbineRpmActualValue += (p.turbineRpmSettingValue - p.turbineRpmActualValue) / ((p.turbineRpmActualValue + 1) * 5)
			p.turbineRpmActualValue *= 0.998
			p.turbineRpmActualValue += p.turbineRpmActualValue * rand.Float64() * 0.004

			// 加速度の計算
			p.acceleration = float64(p.turbineRpmActualValue / 10.0)

			// 速度の計算
			p.velocity += p.acceleration / 10
			p.velocity *= 0.99 + rand.Float64()*0.003 // 減速係数

		case <-ctx.Done():
			return
		}
	}
}

各種ウィジェットの表示更新処理

計器類の表示更新処理です。これらはGoroutine生成時に引数として渡されるほか、レイアウト設定時にも呼び出されます。後述する main()メソッド内のウィジェット生成部分と対比して見てください。

func speedometer(ctx context.Context, p *Player, display *segmentdisplay.SegmentDisplay, delay time.Duration) {
    ticker := time.NewTicker(delay)
    defer ticker.Stop()

    for {
        select {                                                                                                                                                          
        case <-ticker.C:
            if err := display.Write([]*segmentdisplay.TextChunk{
                segmentdisplay.NewChunk(fmt.Sprintf("%06.1f", p.velocity)),
            }); err != nil {
                panic(err)
            }
        case <-ctx.Done():
            return
        }
    }
}

以下では、棒状のゲージである Gauge の描画を行っています。
Gauge.Absolute() は、絶対値の最大値をもつゲージを描画します。
ほかにも、Gauge.Percent()があり、こちらは100が最大となります。

// タービン回転数設定値ゲージ
func rpmSettingGauge(ctx context.Context, p *Player, g *gauge.Gauge, delay time.Duration) {
	ticker := time.NewTicker(delay)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			displayValue := int(math.Max(math.Min(float64(p.turbineRpmSettingValue), 200.0), 0))
			if err := g.Absolute(displayValue, 200); err != nil {
				panic(err)
			}
		case <-ctx.Done():
			return
		}
	}
}

次の関数では、ドーナツ型のゲージである Donut を描画しています。
こちらもGauge同様、Absoluteで絶対値を最大値に設定しています。
また、回転数に応じてゲージの色を変化させています。

// タービン回転数ゲージ
func rpmMeterDonut(ctx context.Context, p *Player, d *donut.Donut, delay time.Duration) {
	ticker := time.NewTicker(delay)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			displayValue := math.Max(math.Min(float64(p.turbineRpmActualValue), 200.0), 0)

			if displayValue < 140 {
				if err := d.Absolute(int(displayValue), 200, donut.CellOpts(cell.FgColor(cell.ColorYellow))); err != nil {
					panic(err)
				}
			} else {
				if err := d.Absolute(int(displayValue), 200, donut.CellOpts(cell.FgColor(cell.ColorRed))); err != nil {
					panic(err)
				}
			}

		case <-ctx.Done():
			return
		}
	}
}

メインの処理

以下は main() 処理からの抜粋になります。つまり、上から順次実行されていく、ということです。まずはプレイヤーのステータス初期化ですね。

func main() {
	// プレイヤーの状態初期化
	player := Player{
		turbineRpmSettingValue: 0,
		turbineRpmActualValue:  0,
		velocity:               0.0,
		acceleration:           0.0,
	}
...

各種ウィジェットの実体を生成する

New()メソッドにて、各ウィジェットの実体を生成しています。
ボタンなど、再描画を必要としないウィジェットも一旦は生成する必要があります。
また、Donut(円グラフ)の帯部分の幅など、いくつかの初期設定もここで行っています。

    // セグメントディスプレイ: 速度計                                                                                                                                              
    display, err := segmentdisplay.New()
    if err != nil {
        panic(err)
    }


    // ボタン: 設定値をプラス                                                                                                                                             
    buttonTurbinePlus, err := button.New("+ 10", func() error {
        // process                                                                                                                                                        
        player.turbineRpmSettingValue += 10                                                                                                                               
        if player.turbineRpmSettingValue > 200 {
            player.turbineRpmSettingValue = 200
        }
        return nil
    })
    if err != nil {
        panic(err)
    }


    // ボタン: 設定値をマイナス                                                                                                                                           
    buttonTurbineMinus, err := button.New("- 10", func() error {
        // process                                                                                                                                                        
	      player.turbineRpmSettingValue -= 10
        if player.turbineRpmSettingValue < 0 {
            player.turbineRpmSettingValue = 0
        }
        return nil
    })
    if err != nil {
        panic(err)
    }


    // 円グラフ: rpm                                                                                                                                                      
    rpmMeter, err := donut.New(
        donut.CellOpts(cell.FgColor(cell.ColorYellow)),
	      donut.HolePercent(50),
        donut.ShowTextProgress(),                                                                                                                                         
        donut.Label("turbine rpm", cell.FgColor(cell.ColorYellow)),
    )
    if err != nil {
        panic(err)
    }


    // 棒グラフ: 設定出力                                                                                                                                                 
    rpmSettingMeter, err := gauge.New(
        gauge.Height(1),
        gauge.Border(linestyle.Light),
        gauge.BorderTitle("Setting Value"),
    )
    if err != nil {
        panic(err)
    }

Goroutineで並行処理する

定期実行したいメソッドはupdateTick(時間経過でパラメータ変化させるメソッド)と、
前述のウィジェット再描画用メソッドですべて出揃っているので、
これらを使ってGoroutineを作成します。
updateTickは16ミリ秒間隔で実行されますが、
負荷のことも考えて他のウィジェットの再描画はそれぞれ異なる間隔で行っています。

    // goroutine作成                                                                                                                                                      
    go rpmMeterDonut(ctx, &player, rpmMeter, 100*time.Millisecond)
    go rpmSettingGauge(ctx, &player, rpmSettingMeter, 250*time.Millisecond)
    go speedometer(ctx, &player, display, 16*time.Millisecond)
    go updateTick(ctx, &player, 16*time.Millisecond)

レイアウト設定

そして最後に、端末中のどこにどのウィジェットを描画するかをここで設定します。
かなり散らかったコードではありますが、大まかに説明すると、
container.New()でオブジェクト生成時のパラメータとしてウィジェットの配置を設定しています。
container.PlaceWidget()で指定のウィジェットを配置します。
SplitHorizontal()で、端末を上下2分割し、 Top()とBottom()内にそれぞれウィジェットを配置できますし、さらにSplitHorizontal()できます。
SplitHorizontal()ではなくSplitVertical()することで左右2分割でき、 Left()とRight()内に、同様にウィジェット配置やさらなる分割ができます。
また、ウィジェット配置だけでなく、ボーダーラインやタイトルも設定できます。

	// Layout ----------------------------------------------------------------------
	c, err := container.New(
		t,
		container.Border(linestyle.Light),
		container.BorderTitle("PRESS Q TO QUIT"),
		container.SplitHorizontal(
			container.Top(
				container.Border(linestyle.Light),
				container.BorderTitle("Current Speed: (kt)"),
				container.PlaceWidget(display),
			),
			container.Bottom(
				container.Border(linestyle.Light),
				container.BorderTitle("Turbine Control"),
				container.SplitVertical(
					container.Left(
						container.SplitHorizontal(
							container.Top(
								container.PlaceWidget(rpmSettingMeter),
							),
							container.Bottom(
								container.SplitVertical(
									container.Left(
										container.PlaceWidget(buttonTurbinePlus),
										container.AlignHorizontal(align.HorizontalCenter),
									),
									container.Right(
										container.PlaceWidget(buttonTurbineMinus),
										container.AlignHorizontal(align.HorizontalCenter),
									),
								),
							),
						),
					),
					container.Right(
						container.Border(linestyle.Light),
						container.BorderTitle("rpm"),
						container.PlaceWidget(rpmMeter),
					),
				),
			),
		),
	)
	if err != nil {
		panic(err)
	}

以上で、コードの中で何をやっているかはだいたい説明したと思います。
実際に動かしたり、値を変化させたりして遊んでみましょう。

おわりに

今回は勢いでコードを書いてしまいましたが、Goroutineで動かしたい関数を定義し、それを実際に走らせる流れはおわかりいただけたでしょうか?
複数のGoroutineを制御するにはチャネルの理解が不可欠です。(私ですか?私はこれから勉強します😉)
このあとは、「方角」の概念を追加し、2次元の平面上を移動できるようにします。
そして、ウィジェットAPIより一段下のレイヤに手を突っ込んで、ド○ゴン○ーダーのようなものを描画してみようと思います。

それではまた、次回お会いしましょう。
ご覧下さいまして、ありがとうございました!

コメントを残す

メールアドレスが公開されることはありません。