🗂

golangでタイピングゲームを作ってみた

2021/09/04に公開

こんにちは、うえむーです。

職業はフロントエンドエンジニアをやっており、プライベートではTypescript・React.js・next.js等様々な言語・フレームワークを学習しております。
今回は2019年10月にgolangを学習し、タイピングゲームを作ったのでつまづいた事などをブログに書きたいと思います。

Golangというのはなんだろう?

「Golang」は、Googleによって開発されたサーバーサイド言語の1つであり、2009年にリリースした新しい言語になります。

そもそもフロントエンドエンジニアの僕がgolangをやりたかった理由は3つあります。

・[Gopher]というマスコットキャラクターがいい!
・2019年 学びたいプログラミング言語、トップ3入り!
・progateのプログラミング学習にその言語が入っていて面白そうだったから

Golangはどういったところで使うのか?

・Webサーバー・アプリケーション
一番利用シーンが多いと思われるのはWebサーバー・アプリケーションだそうです。
実際に処理が実行されるのは裏側であり、表側からは見えません。ユーザーは処理結果だけを見るというのがクラウドコンピューティングの概念だそうです。実際にyoutubeのサイトもgolangを使っているそうです。

この分野ではPHP・java・rubyが使われているんですが、後ほど記載するgoの特徴が評価され、先ほど記述しましたが2019年の学びたい言語ランキングだと、golangは3位で、Javascript・Physonの次に人気があり。開発者のgoogleはもちろん、日本企業でもgolangでの新規開発が進んでいるみたいです。

Golangの特徴

さて、続いてGolangのプログラミング言語の特徴についてお話します。
まだ浅いのでまだまだわからないところがあるのですが、調べたところ特徴は大きく分けて7つあるみたいです。

  1. 実行速度が速い
  2. 並列処理に強い
  3. 拡張性が高い
  4. 環境に依存しない
  5. 消費リソースが少ない
  6. シンプルな言語構造
  7. 誰が書いたコードでも読みやすい

サーバーサイド言語はPHPで書いてきましたが、Golangでタイピングゲームを作ってPHPと比較して共感したことは「2 並列処理に強い」「4 環境に依存しない」「6 シンプルな言語構造」です。
自分が共感した3つの特徴についてお話したいと思います。

2 並列処理に強い

大量のデータ処理には並列処理が必要となりますが、Go言語は並列処理を得意としており、チャネルなどの機能を使うことでCPUへの負荷などを気にせずに処理を進められます。Go言語は、PHPが苦手とする並列処理やエラーチェックが得意なことなどから、PHPに代わる言語として使用されるケースも増えているそうです。うちが勤めているところはsymfonyであり、バージョンも古く処理が非常に遅いので、この特徴を知って関心しました。

4 環境に依存しない

"ソースコードを元に、開発しているOSとは異なる環境向けに実行可能なコードを生成すること" が可能であるため、OSやソフトウェアが異なるシステム上でも実行できるそうです。環境に依存するdockerはmacだとすぐにインストールできるものの、windowsの場合はインストールする際に何かかまさないといけないらしく、インストールするのに時間がかかったと聞いていますのでgolangでは環境に依存しないの良いなと思ってます。

6 シンプルな言語構造

複雑な機能は削られており至ってシンプルな言語構造になっています。言語をシンプルにすることで、高速コンパイルの実現やミスの軽減に役立っているようです。
Progateのプログラミング学習は平均時間を8時間のところ、6時間で終わらせミスるとどこのコードがミスっているのがわかりづらいためGo言語はどこをミスっているのか見つかりやすいのですごく助かっています。

次に、タイピングゲームを作ったことについてお話しします。

タイピングゲームを作ったきっかけ、挑戦した事、つまづいた事

僕はgolangでタイピングゲームを作ろうとしたきっかけは「progate」です。
学習コースに「golang タイピングゲーム作成」がありましたので、基礎を学び・簡単なタイピングゲームを作成しました。

でもこれじゃ物足りなかったので以下の機能を追加して個人の環境で作成しました。

・問題をランダム表示
・制限時間付き

この機能を追加して実装したのですが色々とつまづいた事が沢山ありました。
つまづいたことを2つお話していきたいと思います。

タイピングゲームを作成してつまづいた事 その1「Scan関数の罠」

最初はタイマーなし・ランダム表示機能なしでタイピングゲーム作成しました。
コードは以下のような感じです。


package main
import (
    "fmt"
    "os"
    "bufio"
)

func main() {
    totalScore := 0
    // 引数にtotalScoreのポインタを渡してください
    ask(1, "dog", &totalScore)
    ask(2, "cat", &totalScore)
    ask(3, "fish", &totalScore)
    ask(4, "Tiger", &totalScore)
    ask(5, "Elephant", &totalScore)
    ask(6, "Crocodile", &totalScore)
    ask(7, "this is a dog", &totalScore)
    ask(8, "Amphibians", &totalScore)
    ask(9, "Butterfly", &totalScore)
    ask(10, "excellentswimmer", &totalScore)

    fmt.Println("スコア", totalScore)
    if totalScore <= 30 {
      fmt.Println("頑張りましょう!")
    } else if totalScore <= 60 && totalScore >= 31 {
      fmt.Println("もう少しです!")
    } else if totalScore <= 80 && totalScore >= 61 {
      fmt.Println("なかなかいいですね!")
    } else if totalScore >= 81 {
      fmt.Println("素晴らしい!!")
    }
}
// 渡されるtotalScoreのポインタを受け取るように変更してください
func ask(number int, question string, scorePtr*int) {
    var ans string
    fmt.Printf("[質問%d] 次の単語を入力してください: %s\n", number, question)
    fmt.Scan(&ans)
    if question == ans {
        fmt.Println("正解です!")
        // ポインタを使って加算してください
        *scorePtr += 10        
    } else {
        fmt.Println("不正解です!")
    }
}

Progateを参考にしてプログラミンングして実行した所、思いもよらないバグが発生しました。
タイピングゲームの7問目に半角スペースを含めたワード「this is a dog」と設定し実行すると。

ask(7, "this is a dog", &totalScore)

正解したはずなのに、「不正解です!」と返され8問目〜10問目が飛ばされてしまうバグが発生してしまいました。

googleで色々と検索した所、原因はScan関数らしく公式ドキュメントを見ると以下のことが書かれておりました。。。

Scan関数

func Scan( a ...interface{} ) (n int, err os.Error)

Scanは、標準入力から読み込んだテキストをスキャンし、スペースで区切られた値として、順に引数に格納します。改行文字はスペースとしてカウントされます。この関数はスキャンに成功した項目数を返します。この数が、引数の数より少ないときは、errにその理由を返します。

つまり、自動で空白区切りで順に帰ってくることがわかったので、その解消方法を色々と調べて以下のように書き直しました。

訂正前


// 渡されるtotalScoreのポインタを受け取るように変更してください
func ask(number int, question string, scorePtr*int) {
    var ans string
    fmt.Printf("[質問%d] 次の単語を入力してください: %s\n", number, question)
    fmt.Scan(&ans)
    if question == ans {
        fmt.Println("正解です!")
        // ポインタを使って加算してください
        *scorePtr += 10        
    } else {
        fmt.Println("不正解です!")
    }
}

訂正後


// 渡されるtotalScoreのポインタを受け取るように変更してください
func ask(number int, question string, scorePtr*int) {
    var ans string
    fmt.Printf("[質問%d] 次の単語を入力してください: %s\n", number, question)
    sc := bufio.NewScanner(os.Stdin)
    if sc.Scan() {
       ans = sc.Text()
    }
    if question == ans {
        fmt.Println("正解です!")
        // ポインタを使って加算してください
        *scorePtr += 10        
    } else {
        fmt.Println("不正解です!")
    }
}

標準入力からテキストを一行ずつ読み込む「bufio.NewScanner」の関数を利用しました。
標準入力から読み込んだテキストをスキャンできたら、テキストを文字列に変更し「ans 変数」に渡して、
「ans 変数」と問題が同一であれば「正解です!」という文字列を出力 + ポイント加算し、
同一でなければ「不正解です!」 という文字列を出力するように処理に変更しました。

その結果、「this is a dog」と入力しても、以下のように不具合ににならず正常に処理ができました。

タイピングゲームを作成してつまづいた事 その2「制限時間設定」

不具合解消したので、上記に記載した通り以下を実装しました。

・問題をランダム表示
・制限時間付き

最初は「問題をランダム表示」の実装に入りました。
shuffle関数を利用すれば、問題をランダム表示することができました。


// 配列をシャッフルする
func shuffle(data []string) {
    n := len(data)
    rand.Seed(time.Now().Unix())
    for i := n - 1; i >= 0; i-- {
        j := rand.Intn(i + 1)
        data[i], data[j] = data[j], data[i]
    }
}

次は、タイマー設定の実装です。
まず、フラグを作成するために「flag」パッケージを利用して、制限時間のフラグを作成します。


var t int // 初期設定

func init() { // フラグ作成
    //オプションで制限時間をできる
    flag.IntVar(&t, "t", 1, "制限時間(分)")
    flag.Parse()
}

フラグを作成した後に「time」パッケージを利用して、現在時刻 + 指定した時間以降の変数を作成をします。

        tm        = time.After(time.Duration(t) * time.Minute)

タイピングゲームを作成するので上記でお話しした「bufio.NewScanner(os.Stdin)」のパッケージを利用して入力データ「バイト」を読み出すためのインターフェースを作成します。
「bufio.NewScanner(os.Stdin)」を利用して入力したデータ「バイト」を読み出しするときに、
go funcを利用してと非同期処理として実行させます。その関数がないと、同期処理として実行されお題が出力できない状態になります。


ch_rcv    = input(os.Stdin) 

。。。。

func input(Stdin io.Reader) <-chan string {
    channel := make(chan string)
    go func() {
        strings := bufio.NewScanner(Stdin)
        for strings.Scan() {
            channel <- strings.Text()
        }
    }()
    return channel
}

タイマー設定の変数・入力データ「バイト」読み出しのインターフェース・シャッフル関数を作成したので、
それを組み合わせてタイピングゲームを作成します。そのコードは以下になります。


package main

import (
    "bufio"     // buffered「データ転送」をやるためのもの
    "flag"      // コマンドラインのフラグを解析
    "fmt"       // 文字列の入出力
    "io"        // ioパッケージ インターフェース
    "os"        // osパッケージ
    "time"      // タイマー
    "math/rand" //ランダム
)

// 配列をシャッフルする
func shuffle(data []string) {
    n := len(data)
    rand.Seed(time.Now().Unix())
    for i := n - 1; i >= 0; i-- {
        j := rand.Intn(i + 1)
        data[i], data[j] = data[j], data[i]
    }
}

var t int

func init() {
    //オプションで制限時間をできる
    flag.IntVar(&t, "t", 1, "制限時間")
    flag.Parse()
}

func main() {
    var (
        ch_rcv    = input(os.Stdin) 
        tm        = time.After(time.Duration(t) * time.Minute)
        words     = []string{ "raccoon"。。。 "excellentswimmer is long language" }
        score     = 0
    )
    fmt.Println()
    shuffle(words);
    fmt.Println("タイピングゲームを始めます。制限時間は", t, "分。1語1点、", len(words), "点満点")
    //送信用チャネル
    num := 1
    for i := true; i && score < len(words); {
        question := words[score]
        fmt.Print("[質問", num ,"]次の単語を入力してください:", question, "\n")
        fmt.Print("[答え]")
        select {
        case x := <-ch_rcv:
            //標準入力に何か入力された時の処理
            // 入力された文字が一致しているかどうかをチェックする
            if question == x {
                fmt.Println("正解です!")
                score++
                num++
            } else {
                fmt.Println("不正解です!")
            }
        case <-tm:
            //制限時間が過ぎた際の処理
            fmt.Println("\n制限時間を過ぎました")
            i = false
        }
        
    }
    fmt.Println("あなたの点数:", score, "点 / ", len(words), " 点")
    n := score
    switch {
      case n <= 10:
        fmt.Println("判定 F")
。。。
      case n > 45:
        fmt.Println("判定 SSS")
      default:
        fmt.Println("判定 F")
    }
}

// 入力コードを読み出すためのインターフェース
func input(Stdin io.Reader) <-chan string {
    channel := make(chan string)
    go func() {
        strings := bufio.NewScanner(Stdin)
        for strings.Scan() {
            channel <- strings.Text()
        }
    }()
    return channel
}

for文で配列を回してお題をランダムで出力させるように設定し、switch文と似ているselect文を利用して条件分岐指定しました。
文字列を入力を入力し正解だったら正解ですと文字列を返し、スコアが加算され次の課題へ不正解だったら加算されず正解が出るまで次のお題出さないように設定してます。
時間がすぎたら「制限時間すぎました」という文字列を出力し強制終了させるように実装しました。

その結果、以下のように正常に実装できました。

go langは慣れなかったので色々とつまずきましたが、いい刺激になりました。
落ち着いたらこれを応用化してフロントに反映したいと思ってます。

github

https://github.com/uemura5683/golang-typinggame

参考記事
https://qiita.com/TsubasaSato/items/92f12af9d770bc93951f

https://www.sprasia.co.jp/blog/tech-golang

Discussion