Bubble Teaでマインスイーパー作った

昨年ごろ、ふとインタラクティブCLIを作りたいと思い、色々調べてみたらBubble Teaというものを見つけたのだが、作りたかったCLIは諸事情で不要になったので、その内触りたいなぁなんて思っていたら完全に忘れてしまっていた。
この前Bubble Teaに関する記事を見かけたので、なんかターミナルアプリ作ってみるか、という気分になったので手始めにマインスイーパーを作ってみた。

github.com

Bubble Teaとは

タピオカミルクティーターミナルアプリを構築するための、Elm Architectureに基づいたGo製のフレームワークらしい。

github.com

実装

tea.Modelを実装する構造体を作る

https://github.com/charmbracelet/bubbletea/blob/master/tea.go

type Model interface {
    Init() Cmd
    Update(Msg) (Model, Cmd)
    View() string
}

このinterfaceを実装する構造体を軸に、init()で初期化し、View()でstring型で描画し、操作するたびUpdate(Msg)が呼び出され再度View()が呼ばれるという感じ。

サンプル的に簡単なアプリ作って説明しようと思ったけど、既に素晴らしい記事があったのでそちらを貼り付けさせていただきます。

motemen.hatenablog.com

今回実装したmodelは以下の通り

type model struct {
    column   int       // 列数
    row      int       // 行数
    bombnum  int       // 地雷数
    gameover bool      // ゲームオーバー判定用
    remain   int       // 地雷以外の残りマス数
    num      [9]string // 描画用
    points   [][]point // マス内のデータ
}

各マスごとに保持するデータは以下

type point struct {
    data    int  // 周りの爆弾数(地雷の場合は-1)
    opened  bool // 既にOPENしたか判定
    flagged bool // フラグが立っているか判定
}

初期化

func (m model) Init() tea.Cmd {
    return nil
}

アプリ起動時に実行したいコマンドを定義できるが、今回は利用せず上記のようにした。

別途起動時に行数と列数と爆弾数を入力して、入力値に従ったマスとマス内のデータの初期設定を行う関数を実装して、modelの初期設定を行った。(詳細は割愛)

func InitialModel() (model, error) {
    m := model{}
    // m.columnを初期化する処理
    // m.rowを初期化する処理
    // m.bombnumを初期化する処理
    // m.bombnumの数だけm.points[x][y]にランダムで-1を格納する処理と爆弾が入らないマスに周りの爆弾数を格納する処理
}

View()

string型で描画するので設定した行数×列数のマスと、爆弾数を表示する文字列を構築する実装をした。

func (m model) View() string {
    var s strings.Builder
    s.WriteString(m.viewHeader())
    s.WriteString(top(m.column - 1))

    for i := 0; i < m.row; i++ {
        s.WriteString(cellLeft())
        for j := 0; j < m.column; j++ {
            if m.gameover {
                m.points[i][j].opened = true
            }
            s.WriteString(m.cellMiddle(m.points[i][j].data, m.points[i][j].opened, m.points[i][j].flagged))

        }

        s.WriteString(cellRight())

        if i < m.row-1 {
            s.WriteString(middle(m.column - 1))
        } else {
            s.WriteString(bottom(m.column - 1))
        }
    }

    if m.gameover {
        s.WriteString(gameover())  // 地雷を開いた場合はゲームオーバー表示
    } else if m.remain == 0 {
        s.WriteString(gameclear()) // 未開封マスが地雷のみの場合はゲームクリア表示
    }

    return s.String()
}

行数、列数が10で、地雷数が15の場合の初期表示

Update(Msg)

ここでキーボードかマウス操作があった際の処理を行う。
描画に必要なデータをmodelのメンバに定義して、Update()で操作内容に応じてメンバを更新してやるのが基本的な使い方だと認識している。
ここではマウス操作(クリック or command+クリック)時にどのマス上での操作かを判定して操作内容に応じた処理(マスを開く or フラグを立てる)を実装している。
またマウス操作ではなくキーボードでctrl+cかqを押下された際にアプリケーションを終了する処理を実装している。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

    // マウス操作かキーボード操作かで処理を切り分ける
    switch msg := msg.(type) {

    // マウス操作の場合
    case tea.MouseMsg:

        // ゲームオーバー or ゲームクリア判定が出ている場合は操作不可
        if m.gameover || m.remain == 0 {
            return m, nil
        }

        // マウス操作されたマスを判定
        col, row := m.cell(msg.X, msg.Y)
        if col == -1 || row == -1 {
            return m, nil
        }

        //  Alt(Macはcommand) + クリックの場合でまだ開いていないマスであればフラグを立てる(立ってたら降ろす)
        if msg.Alt && msg.Type == tea.MouseLeft && !m.points[row][col].opened {
            if m.points[row][col].flagged {
                m.points[row][col].flagged = !m.points[row][col].flagged
                m.bombnum++
            // 既に立てたフラグ数が爆弾数以上になってなければフラグを立てる
            } else if m.bombnum > 0 {
                m.bombnum--
                m.points[row][col].flagged = !m.points[row][col].flagged
            }
            return m, nil
        }

        if msg.Type != tea.MouseLeft {
            return m, nil
        }
        return m.choose(col, row), nil

    // キーボード操作の場合
    case tea.KeyMsg:

        switch msg.String() {
        // ctrl+cかqの場合はアプリ終了
        case "ctrl+c", "q":
            return m, tea.Quit
        }
    }
    return m, nil
}

アプリケーション起動

tea.NewProgramにmodelとその他オプションを引数に入れて、Start()でアプリを起動できます。
今回はWithAltScreen()というフルウィンドウモードで実行されるオプションとWithMouseCellMotion()というマウス処理を行うオプションを指定します。

func main() {

    m, err := ms.InitialModel()
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    p := tea.NewProgram(
        m,
        tea.WithAltScreen(),
        tea.WithMouseCellMotion(),
    )

    err = p.Start()
    if err != nil {
        fmt.Println(err)
    }

}

操作したマスの判定

先程のUpdate()の中で操作しているマスを判定している処理を再掲する。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

    switch msg := msg.(type) {
    case tea.MouseMsg:
        if m.gameover || m.remain == 0 {
            return m, nil
        }
        col, row := m.cell(msg.X, msg.Y)

Updateの引数に入ってくるtea.Msgはempty interface。

type Msg interface{}

マウス操作の場合はtea.MouseMsgがキーボード操作の場合はtea.KeyMsgが入ってくるので、まずはその切り分けを行い処理する。
tea,MouseMsgは以下の通り、MouseEvent構造体型になっていて、マウス操作時の情報をメンバにもっている。

type MouseMsg MouseEvent

type MouseEvent struct {
    X    int
    Y    int
    Type MouseEventType
    Alt  bool
    Ctrl bool
}

今回は操作した際の座標情報がX,Yに格納されてくるので、その値を利用してどのマスの操作かを判定する。

func (m model) cell(x, y int) (int, int) {
    col := (x - marginLeft) / cellWidth
    row := (y - marginTop) / cellHeight

    return toDisplayNum(col, m.column), toDisplayNum(row, m.row)
}

XとYはターミナル上の座標のデータが入ってくるので、描画時に整形したマージン分ずらしてやって、マスの長さで割ると操作したマスの特定ができる。

操作がクリックであれば、開封処理を、command+クリックであればフラグを立てるなどの処理行い、地雷がセットされているマスを開封するか、地雷がセットされていないマスを全て開封するまでゲームを続ける。

今後の改善予定箇所

  • タイムアタック
    非同期処理で右上とかに時間を表示させる処理を入れたい

  • キーボードのみで操作
    もともとはキーボードのみでマスを移動して操作するような形で考えていたが、マウス操作がどこまで上手く処理できるのか知りたくてキーボード操作は実装しなかったが、どっちでもできるようにしてみたい

参考

同じようにBubble Teaで作ったチェスアプリを見つけたので(かなり)実装の参考にした。 github.com