虎の穴ラボ技術ブログ

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

MENU

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

こんにちは! 虎の穴ラボのA.Mです。
この記事は「虎の穴ラボ 夏のアドベントカレンダー」の13日目の記事です。
12日目ははっとりさんによる「いろいろなObserver APIの紹介」が投稿されました。
14日目はおっくんさんによる「位置情報 AR にスマホのブラウザだけでチャレンジ」が投稿されます。こちらもぜひご覧ください。

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

目次

前提

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

開発・実行環境

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

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

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

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

  • Go言語:Go 1.18

ゲーム概要

魔王によってダンジョンに囚われた姫が、勇者の助けを待ちきれずに自力で脱出するシンプルなゲームです。 なお、魔王やその他魔物的なエネミーは未実装です。
メニュー選択は[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

注意事項

  • Ctrl+Cで強制終了すると、ターミナルの挙動がおかしくなるのでご注意ください
  • もしターミナルの挙動がおかしくなった場合は、exitでDockerコンテナを一旦終了すれば直ります

マップ構成

以下のような4×6の合計24部屋で構成されたダンジョンを作成します。
マップの通路(各部屋のドア配置)については事前に考えておいた方が実装がスムーズです。

実際に実装してみる

ディレクトリ構成

Go言語のお作法に則り、以下のようなディレクトリ構成で作成しています。

  • assets:静的なファイル
    • タイトル画面やマップ表示用のアスキーアートなど
  • cmd:アプリケーションのエントリーポイント
    • main.go
  • internal:エントリーポイントから呼ぶライブラリ(他アプリケーションと共有しないもの)
    • 主にゲーム固有のオブジェクト、処理など
  • pkg:エントリーポイントから呼ぶライブラリ(他アプリケーションから呼ばれても問題ないもの)
    • システムコマンド実行、ファイルを開く等の汎用的な処理

▼ディレクトリ構成全体

.
├── Dockerfile
├── assets
│   ├── 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
│   │   └── wana.go
│   ├── event
│   │   └── game_clear.go
│   ├── player
│   │   └── player.go
│   ├── room
│   │   └── room.go
│   └── title
│       ├── rule.go
│       └── title.go
└── pkg
    └── system
        └── system.go

オブジェクト(構造体)の定義

主なオブジェクトは、以下の3つです。

  • Room:ダンジョンの構成要素
  • Dungeon:ダンジョン
  • Player:プレイヤー

Room:ダンジョンの構成要素

Roomは次のような要素を持ちます。
方向を示すDirectionの定義はどこで持つか悩みましたが、Dungeonに持たせるとRoomから参照した場合に循環参照になってしまうので、Roomに持たせました。

// 東西南北の方向の定義
type Direction int

const (
    North Direction = iota
    East
    South
    West
)

type Room struct {
    HasDoor   [4]bool // 各方向のドアの有無
    IsVisited bool    // プレイヤーが部屋に来たことがあるかどうか
    HasHint   bool    // ヒントの表示有無
    IsWana    bool    // 罠の有無
    IsGoal    bool    // ゴールかどうか
}

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

▼Public

  • func (r *Room) Display(dir Direction)
    • 指定の方向を向いているときの部屋の様子を一人称視点のアスキーアートで表示する

▼表示例

Dungeon:ダンジョン

Dungeonは次のような要素を持ちます。

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

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

▼Public

  • func Create(p player.Player) *Dungeon
    • ダンジョン生成(初期化)
  • func (d *Dungeon) Display()
    • 部屋の表示処理を呼び出す
  • func (d *Dungeon) CheckEvent() bool
    • イベント処理として以下を行う
      • 入った部屋を探索済みにする
      • 罠やゴールなどの判定
  • func (d *Dungeon) WaitAction()
    • プレイヤーの入力を待ち、入力から以下を実行する
      • プレイヤーの移動
      • ダンジョンマップの表示(できれば分離したい、、)
  • func (d *Dungeon) PrintMap()
    • ダンジョンマップを表示する処理
      • メソッドの中身が複雑で行数が多いので、同一パッケージ内で別ファイルに切り出し

▼Private

  • func (d *Dungeon) explore(dir room.Direction)
    • ダンジョンの探索を行い、進行方向にドアがあれば次の部屋への移動を行う
  • func (d Dungeon) currentRoom() room.Room
    • プレイヤーが現在いる部屋を返す
    • いろんなところから呼ばれる

Player:プレイヤー

Playerは次のような要素を持ちます。

type Player struct {
    RoomX     int            // プレイヤーがいる部屋のX座標
    RoomY     int            // プレイヤーがいる部屋のY座標
    Direction room.Direction // 向いている方向
}

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

▼Public

  • func NewPlayer() *Player
    • プレイヤーオブジェクトを作成(コンストラクタ相当)
  • func (p *Player) SetPosition(x int, y int, dir room.Direction)
    • プレイヤーの位置を指定する
    • 主に初期化等で使用
  • func (p *Player) Move(dir room.Direction)
    • 指定方向(東西南北)に、プレイヤーの座標を移動する
  • func (p *Player) Rotate(r Rotation)
    • 指定方向(右左)に、プレイヤーが向いている方向を回転する
    • ※部屋は移動しない

肝となる仕組み・ライブラリ

システムコマンドの実行

ターミナルで各種表示を行う都合上、各所でclearコマンドで画面をリフレッシュする必要があります。 そのため、以下のような関数を実装し、システムコマンドが手軽に実行できるようにしています。

// 外部コマンド実行用
// C言語のsystem関数と同等の動作を行う
func System(cmd string) int {
    c := exec.Command("sh", "-c", cmd)
    c.Stdin = os.Stdin
    c.Stdout = os.Stdout
    c.Stderr = os.Stderr
    err := c.Run()

    if err == nil {
        return 0
    }

    // 終了ステータス(Exit code)を返す
    if ws, ok := c.ProcessState.Sys().(syscall.WaitStatus); ok {
        if ws.Exited() {
            return ws.ExitStatus()
        }

        if ws.Signaled() {
            return -int(ws.Signal())
        }
    }

    return -1
}

▼呼び出し例

system.System("clear")

ターミナル上での文字装飾

標準入力する際に、エスケープシーケンスを出力することで、文字の色や背景色を変更しています。 文字列の出力後に、設定を戻さないと色の変更がずっと適用されてままになってしまうので、忘れないように注意が必要です。

▼実行例

// 進行方向にドアがない場合
fmt.Printf("\x1b[41m") // 背景色を赤に変更
fmt.Printf("\nその方向には進めません!\n")
fmt.Printf("\x1b[49m") // 背景色を戻す

エスケープシーケンスの一覧については、こちらのサイトがまとまって分かりやすいです。
life-is-command.com

Enterキーを必要としない入力の受付

通常の標準入力では、Enterキーを押さないと入力がされません。
しかし、ゲームとして操作する上では、都度[Enter]キーを押すのは非常に操作性が宜しくないため、[W][A][S][D]キーを押すだけで移動できるように実装しています。

今回は、mattn さんが作成されている以下のライブラリの tty.ReadRune()を使用して実現しています。

github.com

▼使用例

tty, err := tty.Open()
if err != nil {
    log.Fatal(err)
}
// 関数から抜けるときに最後に実行
// ここが実行されないとターミナルの挙動がおかしくなるので注意
defer tty.Close()

for {
    r, err := tty.ReadRune()
    if err != nil {
        log.Fatal(err)
    }
    switch r {
    case 119:
        // wが入力された場合
    case 115:
        // sが入力された場合
    case 97:
        // aが入力された場合
    case 100:
        // dが入力された場合
    }
}

おわりに

ターミナル上の文字列操作で動作するゲームについて、ざっくりと解説しました。 細かい部分など説明しきれていない部分もあるかと思いますが、気になる方はソースコードを読んでみてください。

github.com

実はこのゲーム、学生時代にC言語で作ったゲームをリメイクしたものだったりします。
ソースコードを見ると、一部その名残が残っている部分もあるかもしれません。
ちなみに、画面が縦長になっているのは、当時iPadをサブディスプレイにしてニンテンドーDSのような2画面構成にしていたのが理由です。

C言語版では、探索中に一定確率で敵とエンカウントし、戦闘シーン(なお、戦闘はタイピング)も実装しているので、Go言語版でも今後少しずつアップデートして機能を追加していく予定です。
更新した際には、また本ブログにてご紹介したいと思います。

P.S.

虎の穴ラボでは、私たちと一緒に新しいオタク向けサービスを作る仲間を募集しています。
詳しい採用情報は以下をご覧ください。
yumenosora.co.jp