虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

【Go言語】アスキーアートでダンジョンから脱出するゲームを作ってみた【その2】

こんにちは! 虎の穴ラボのA.Mです。
この記事は「虎の穴ラボ 夏のアドベントカレンダー」の20日目の記事です。
19日目はH.Kさんによる「壊滅的に絵を書くことが苦手でもなんとかしてくれる!p5.js × ChatGPTで クリエイティブコーディング!」が投稿されました。
21日目はH.Hさんによる「三日坊主を回避するために始めたこと」が投稿されます。こちらもぜひご覧ください。

今回は「見た目でわかるビジュアルネタ」がテーマということで、昨年作ったUNIXターミナル上で動作するシンプルな脱出ゲームを拡張してみましたので、ご紹介したいと思います。

目次

前提

  • 画像は使いません
  • 代わりにアスキーアートで全て描写します
  • 表示はターミナルの標準出力を使用します
  • 入力はターミナルの標準入力を使用します

また、本記事では今回追加した部分の解説のみを行います。
ゲーム全体の説明はしませんので、詳しく知りたい方は前回の記事をご覧ください。
toranoana-lab.hatenablog.com

開発・実行環境

  • OS:macOS Monterey
    • Linuxで実行した場合、多少表示崩れがあるかもしれません
    • Windowsで正常に動くかどうかは謎です
  • ターミナルの設定(※PC環境よって異なる場合があるので目安です)

    • フォント:SF Mono Regular
      • ※スペースが可視化されているフォントは非推奨です
    • フォントサイズ:12
    • ウィンドウサイズ:80×55以上を推奨

    ※表示が崩れる場合は、フォント・フォントサイズ、ウィンドウサイズを変更して調整してみてください。

  • Docker と Docker Compose がインストール済みであること

  • Go言語:Go 1.20.5
    • ※ 1.18から1.20.5にアップデートしました

ゲーム概要(おさらい)

魔王によってダンジョンに囚われた姫が、勇者の助けを待ちきれずに自力で脱出するシンプルなゲームです。
エネミーとして警備員が実装されました。なお、戦闘はタイピングです。
メニュー選択は[W][S]、決定は[Enter]キーです。
移動は、[W][A][S][D]キーで行います。
探索済みの部屋は、マップとして表示されます(自動マッピング)。

ソースコードはGitHubにて公開していますので、ぜひプレイしてみてください。
github.com

ゲームの起動手順

ここは前回から変わっていません。

# 任意のディレクトリで実行
git clone https://github.com/raku2wei/jailbreak-go.git 

cd jailbreak-go 

docker compose run --rm main

go run cmd/jailbreak/main.go

注意事項

  • 戦闘終了後の初回入力が効かないバグがあります。キーを押しても反応がない場合は再度キーを押してみてください

今回追加した機能

今回追加した機能は主に以下の4つです。

  • エネミーの表示
  • エネミーとのエンカウント
  • エネミーとの戦闘(タイピング)
  • ゲームオーバー処理

現在のディレクトリ構成

今回、いくつかディレクトリとファイルを追加しました。

.
├── Dockerfile
├── assets
│   ├── battle
│   │   └── keibi.txt  # 追加:戦闘時のタイピングテキスト(お題)
│   ├── enemy
│   │   └── keibi  # 追加:エネミーのアスキーアート
│   ├── rooms
│   │   ├── door0
│   │   ├── doorF
(省略)
│   │   └── openDoor
│   └── title
│       ├── logo
│       └── rule
├── cmd
│   └── jailbreak
│       └── main.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── internal
│   ├── dungeon
│   │   ├── dungeon.go
│   │   ├── dungeon_map.go
│   │   ├── encounter.go  # 追加:エネミーのエンカウント処理
│   │   └── wana.go
│   ├── enemy
│   │   ├── enemy.go  # 追加:エネミーのオブジェクト
│   │   └── normal_battle.go  # 追加:エネミーとの戦闘処理
│   ├── event
│   │   ├── event.go  # 追加:イベントの共通処理
│   │   ├── game_clear.go  
│   │   └── game_over.go  # 追加:ゲームオーバー処理
│   ├── player
│   │   └── player.go
│   ├── room
│   │   └── room.go
│   └── title
│       ├── rule.go
│       └── title.go
└── pkg
    └── system
        └── system.go

追加したテキストファイル

assets/battle/keibi.txt:戦闘時のタイピングテキスト(お題)

以下のように、日本語とそのローマ字が交互に記載されているテキストファイルです。
戦闘シーンでは、この中からランダムで選んだお題を表示します。
また、ローマ字の部分は、ユーザーの入力と比較して正誤判定としても利用しています。
内容は四字熟語や、ことわざなどを適当に入れています。

一刀両断
ittouryoudann
右往左往
uousaou
疑心暗鬼
gisinnannki
危機一髪
kikiippatu
弱肉強食
jyakunikukyousyoku

(省略)

assets/enemy/keibi:エネミーのアスキーアート

以下のようなアスキーアートが記載されています。

エネミーのエンカウントと表示

Enemy:エネミーのオブジェクト

Enemyは次のような要素を持ちます。
Enemyのオブジェクトを作るときに、ランダムでお題を決定しています。

// タイピングテキスト(お題)が記載されているファイルのパス
const typingTextFilePath = "assets/battle/keibi.txt"

type Enemy struct {
    Name  string         // エネミーの名前:戦闘開始時に表示される
    FilePath string      // エネミーのアスキーアートのファイルパス
    TextJapanese string  // 戦闘時のタイピングテキスト(日本語)
    TextRomaji string   // 戦闘時のタイピングテキスト(ローマ字)
}

処理としては以下の関数を持ちます。

// コンストラクタ
func NewEnemy(name string, path string) *Enemy {
    // ファイルからランダムでお題を取得
    line := 2 * (rand.Intn(56)) + 1 // ランダムで奇数行を選択
    // お題を保持
    textJapanese := system.LoadLineText(typingTextFilePath, line)
    textRomaji := system.LoadLineText(typingTextFilePath, line + 1)
    return &Enemy{Name: name, FilePath: path, TextJapanese: textJapanese, TextRomaji: textRomaji}
}

// エネミーのアスキーアートを表示する処理
func (n *Enemy) Display() {
    system.PrintFile(n.FilePath)
}

// お題を表示する処理
func (n *Enemy) PrintTypingText() {
    fmt.Println(n.TextJapanese)
    fmt.Println(n.TextRomaji)
}

▼表示例

エネミーとのエンカウント処理

エンカウントについては、まずダンジョンのオブジェクトにエンカウント率を追加しています。 これがダンジョン内で部屋を移動する際のエンカウント率として使用されます。

type Dungeon struct {
    // ダンジョンの部屋は4x6のグリッドなので2次配列
    // (1,1)からスタートとするので配列は5x7
    rooms    [7][5]room.Room
    player   player.Player // ダンジョン内にいるプレイヤー
    moveRoom bool          // プレイヤーが部屋を移動したかどうか(イベントチェック用)
    encounterRate float32  // 追加:エンカウント率 ←new
}

次に、部屋を移動した後のイベント処理関数に、エンカウント処理を追加しています。 また、ゲームオーバーイベントが増えているので、関数の戻り値をboolからevent.Eventに変更しています。

func (d *Dungeon) CheckEvent() event.Event {

    // ...省略

    if d.moveRoom { // 部屋を移動したら判定
        if d.currentRoom().IsWana {
            d.wanaActivate()
        } else if d.currentRoom().IsGoal {
            return event.GameClearEvent
        } else if d.checkEncounter() {    // ここでエンカウント処理を実行
            // エンカウントしたら戦闘処理
            e := enemy.NewEnemy("警備員", "assets/enemy/keibi")
            if !e.Battle() {
                // 負けたらゲームオーバー
                return event.GameOverEvent
            }
            // エンカウント率初期化:初期値は10%
            d.encounterRate = DefaultEncounterRate
        } else {
            // エンカウント率増加:移動するごとに1.4倍になるので、少なくとも7回移動すれば100%エンカウント
      // ※ 10 * 1.4^7 = 105.413504
            d.encounterRate *= EncounterIncreaseRate
        }
        d.moveRoom = false
    }
    return event.NoEvent
}

続いて、エンカウント処理であるcheckEncounterの中身を見ていきます。 処理はシンプルで、1~100の乱数を発生させ、その値がダンジョンのエンカウント率以下であればエンカウントしたと見做しています。

const (
    // エンカウント率のデフォルト値:10%
    DefaultEncounterRate float32 = 10.0
    // エンカウント率の増加率:移動するごとに1.4倍
    EncounterIncreaseRate float32 = 1.4
)

// エネミーとのエンカウント処理
func (d *Dungeon) checkEncounter() bool {
    randomNumber := rand.Intn(100) + 1
    return float32(randomNumber) <= d.encounterRate
}

エネミーとの戦闘(タイピング)

戦闘処理は、少し複雑なので、Enemyとは別のファイルに切り出してinternal/enemy/normal_battle.goで実装しています。
以下のような流れで行います。

  1. 「○○があらわれた!」を表示し、一旦入力を待つ。キーを押すと戦闘がスタート。
  2. ユーザーの入力待ちは、goroutineを使用して別スレッドで行う。※残り時間のカウントダウンをリアルタイムに表示するため
  3. エネミーのアスキーアートや、お題を表示
  4. ユーザーの入力に対して、正誤判定を行った上で表示し、不正解なら赤文字で表示されるようにする
  5. 制限時間内にお題がクリアできなかった場合は、ゲームオーバー処理を行う
  6. クリアした場合は、「You Win!!」と表示してからダンジョンの表示に戻る

ソースコードは少し長いですが、以下のようになっています。 肝となっているのは、Battle関数の中で、selectを使って、入力待ちと画面描写を並列で行っているところです。 これにより、ユーザーの入力待ち中でも、残り時間のカウントダウンをリアルタイムに表示することができます。

func (e *Enemy) Battle() bool {

    time.Sleep(500 * time.Millisecond)

    system.System("clear")

    fmt.Println(e.Name + "があらわれた!")
    fmt.Println("")
    fmt.Printf("Press [any key] to Start")

    event.WaitToPressAnyKey()

    var isMiss bool = false // ミス判定用
    var startTime = time.Now()

    var answer []rune = []rune(e.TextRomaji)
    var playerInput [128]rune
    var m rune = 0
    var n int = 0

    inputChan := make(chan rune)

    tty, err := tty.Open()
    if err != nil {
        log.Fatal(err)
    }

    defer tty.Close()

    // 画面描写をブロックしないように、入力の受付はgoroutineで実施
    go func() {
        for {
            r, err := tty.ReadRune()
            if err != nil {
                log.Fatal(err)
            }

            inputChan <- r
        }
    }()

    for n < len(answer) {
        select {
        case r := <-inputChan:
            if isMiss {
                isMiss = false // ミス判定解除
            }
            if answer[n] == r { // 問題の文字と入力した文字が一致したら
                playerInput[n] = r        // 入力した文字を回答用変数に代入して
                playerInput[n+1] = '\x00' // 終端文字を追加
                n++
            } else { // 一致しなかったらミス判定
                isMiss = true
                m = r
            }
            // 画面の再描写(残り時間計算もここで実施)
            if !refresh(e, isMiss, string(playerInput[:n]), m, startTime) {
                // 時間切れは負け判定
                return false
            }
        default:
            // 画面の再描写(残り時間計算もここで実施)
            if !refresh(e, isMiss, string(playerInput[:n]), m, startTime) {
                // 時間切れは負け判定
                return false
            }
            time.Sleep(100 * time.Millisecond)
        }
    }

    // 時間内に入力できたら勝ち判定
    fmt.Println("\nYou Win!!")
    time.Sleep(1000 * time.Millisecond)

    system.System("clear")

    return true
}

refresh関数は、画面描写を行う関数です。 この中で、残り時間の計算と表示も行っています。

func refresh(e *Enemy, isMiss bool, playerInput string, m rune, startTime time.Time) bool {
    system.System("clear") // 画面クリア

    // エネミーのAAを表示
    e.Display()

    fmt.Printf("\x1b[1m")
    fmt.Printf("「%s」\n", e.TextJapanese) // 問題文(日本語)表示
    fmt.Printf("%s\n", e.TextRomaji)     // 問題文(ローマ字)表示
    fmt.Printf("\x1b[0m")

    fmt.Printf("\x1b[32m")        // 文字色を緑に変更
    fmt.Printf("%s", playerInput) // 入力された文字列を表示
    fmt.Printf("\x1b[39m")        // 文字色を戻す

    if isMiss {
        fmt.Printf("\x1b[41m")        // 文字背景色を赤に変更
        fmt.Printf("%s\n", string(m)) // 入力した文字表示
        fmt.Printf("タイプミス!\n")        // ミスメッセージ表示
        fmt.Printf("\x1b[49m")        // 文字背景色を戻す
    } else {
        fmt.Printf("\n\n")
    }

    timeLimit := battleTimeLimit.Seconds() - time.Now().Sub(startTime).Seconds() // 制限時間(残り時間)計算
    if timeLimit <= 0 {
        timeLimit = 0
    }
    fmt.Printf("残り時間 : %.2f 秒\n", timeLimit) // 制限時間表示(小数点以下2桁まで)
    if timeLimit <= 0 {                      // 時間切れになったらゲームオーバー
        fmt.Printf("TIME OVER!\n")
        return false
    }

    return true
}

▼表示例

ゲームオーバー処理

ゲームオーバー処理は、internal/event/game_over.goに実装しています。
中身はシンプルで、Sleepで0.5秒ごとに1行ずつ「Game Over」のアスキーアートを出力することで、アニメーションのように表示しています。

func printGameOver() {
    system.System("clear")

    fmt.Println(" #####      #     #     #  #######  #######  #     #  #######  ######  \n")
    time.Sleep(500 * time.Millisecond)
    fmt.Println("#     #    # #    ##   ##  #        #     #  #     #  #        #     # \n")
    time.Sleep(500 * time.Millisecond)
    fmt.Println("#         #   #   # # # #  #        #     #  #     #  #        #     # \n")
    time.Sleep(500 * time.Millisecond)
    fmt.Println("#  ####  #     #  #  #  #  #####    #     #  #     #  #####    ######  \n")
    time.Sleep(500 * time.Millisecond)
    fmt.Println("#     #  #######  #     #  #        #     #   #   #   #        #   #   \n")
    time.Sleep(500 * time.Millisecond)
    fmt.Println("#     #  #     #  #     #  #        #     #    # #    #        #    #  \n")
    time.Sleep(500 * time.Millisecond)
    fmt.Println(" #####   #     #  #     #  #######  #######     #     #######  #     # \n")
    time.Sleep(500 * time.Millisecond)
    fmt.Println("")
    time.Sleep(500 * time.Millisecond)
    fmt.Println("ざんねん!!わたしの ぼうけんは これで おわってしまった!!\n")
    time.Sleep(500 * time.Millisecond)
    fmt.Printf("\nPress [any key] to continue.")
}

実行結果は以下のようになります。

うーん、ちょっと読みづらいので、もう少し見やすい文字に変更したいですね。

その他の変更点

main関数の変更

ゲームオーバー処理が追加されたので、イベント処理としては3パターンになりました。

  • ゲームクリア
  • ゲームオーバー
  • イベント発生なし(何もしない)

そのため、CheckEvent関数の戻り値をboolから、event.Eventに変更し、イベントの種類を見て判定するように変更しています。
また、ゲームループの処理にはラベルを追加し、switch文の中からラベルを指定して外側のfor文から抜けるようにすることで、タイトル画面に戻るように変更しています。

func main() {
    for {
        // タイトル表示
        t := title.NewTitle()
        t.Select()

        // ...省略

        // ゲームループ:勝利条件を満たすまで続く
        GameLoop: // ラベル:switch文の中からゲームループを抜けるために追加
            for {
                d.Display() // 部屋の様子を表示

                switch d.CheckEvent() {
                case event.GameClearEvent:
                    event.GameClear()
                    // ゲームループを抜ける(タイトル画面に戻る)
                    // ※ラベルを指定しないと、switchから抜けるだけで外側のfor文からは抜けられないので注意
                    break GameLoop 
                case event.GameOverEvent:
                    event.GameOver()
                    break GameLoop
                }
                // ...省略
            }
    }
}

おわりに

今回は主にエネミーや戦闘シーンの追加について、ざっくりと解説しました。
元のソースコードではC言語固有の実装をしている部分があったので、Go言語で作り直す際は思ったよりも苦労しましたが、goroutineの使い方について理解を深められたのは良かったと思います。

まだバグがあったりするので、時間があるときに修正していきたいと思います。
あとは、書き方がC言語っぽいところやフォーマットが統一されていないところがあるので、その辺りも修正していきたいですね。

細かい部分など説明しきれていない部分もあるかと思いますが、気になる方はソースコードを読んでみてください。

github.com

今回の改修の差分はこちら

github.com

今後の実装としては、やはりゲームなのでどうにかして音(BGMやSEなど)が出るようにしたいところですね。
以前はbeepコマンドで音を出していましたが、Docker環境だと難しいのと環境に依存するので、他にやり方がないか調査中です。

また更新した際には、本ブログにてご紹介したいと思います。

Fantia開発採用情報

虎の穴ラボでは現在、一緒にFantiaを開発していく仲間を積極募集中です!
多くのユーザーに使っていただけるtoCサービスの開発をやってみたい方は、ぜひ弊社の採用情報をご覧ください。
yumenosora.co.jp