ゲームというのは、新しい技術を覚えるのにぴったりの題材です。今回は、ターミナルで遊べる迷路ゲームを作ってRustのエッセンスを学ぶことにしましょう。ターミナル上でカーソル移動やキーの制御をしてみます。
ターミナルの制御するクレート「crossterm」について
今回は、ターミナル上で遊べる迷路ゲームを作ってみます。Rustを使うと高速に動作するマルチプラットフォーム対応のツールが手軽に作成できます。ターミナル上でさまざまな処理ができるようになると、作成するツールの幅も広がることでしょう。頑張れば、viのようなターミナル上で使えるエディタを作ったりすることもできるでしょう。
それで、ここではターミナルの制御を行うのに、crosstermというクレートを使いましょう。これはマルチプラットフォームに対応した多機能なライブラリです。文字の色を変えたり、カーソル移動ができるだけでなく、キー入力イベントや端末サイズを取得したりと、いろいろな機能を備えています。
ここで作る迷路ゲームについて
今回作るのは、次のように、ターミナル上で遊ぶことのできる迷路ゲームです。ターミナルの幅と高さを取得して、ぴったりサイズの迷路を作成します。
画面左上から出発して、右下に到達すればゲームクリアとしました。「@」がプレイヤーです。viと同じキーバインドでk,j,h,lキー(またはカーソルキー)で上下左右に移動します。ゲームを終了するには、[ESC]か[q]キーを押します。
迷路は自動生成で実行する度に異なる迷路を作成するようにします。ここでは、連載の7回目で紹介した「穴掘り法」を使って迷路を作成することにしました。
Cargoでプロジェクトを作成しよう
それでは、Cargoを利用して新規プロジェクトを作成し、crosstermなどのクレートをプロジェクトに追加しましょう。CargoはRustのビルドシステムですが、優秀なパッケージマネージャーでもあります。ターミナルを起動して、下記のコマンドを実行します。
# ディレクトリを作成して移動
mkdir maze && cd maze
# プロジェクトを初期化
cargo init
# ターミナル制御のクレートを追加
cargo add crossterm
# 乱数クレートを追加
cargo add rand
ターミナルに出力する色を変えたりカーソルを動かしたりする方法
それでは、ターミナルを制御する簡単なプログラムを作ってみましょう。まずは、crosstermのいろいろな機能を手軽に使えるように、プログラムの冒頭に下記のような宣言を記述しましょう。
use std::io::{stdout, Result};
use crossterm::{
cursor, execute, ExecutableCommand, terminal,
style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor},
event::{read, Event, KeyCode},
};
最初に画面をクリアして、任意の位置にカーソルを動かして、赤色の文字でメッセージを表示させてみましょう。次のようなコードを書き込みましょう。
fn main() -> Result<()> {
// execute! マクロの中で処理を記述
execute!(
stdout(),
// 画面クリア
terminal::Clear(terminal::ClearType::All),
// カーソル移動
cursor::MoveTo(2, 2),
// 色を赤色に
SetForegroundColor(Color::Red),
// 文字を表示
Print("Hello, World!\n\n\n")
)?;
Ok(())
}
そして、ターミナルから下記のコマンドを実行します。すると、プログラムが実行されます。
cargo run
すると、画面がクリアされて、カーソル位置が(2, 2)に移動し、「Hello, World!」と赤字で表示します。次のような画面が表示されることでしょう。
crosstermでは、execute!マクロが用意されており、このマクロの中にターミナル操作をまとめて記述できるように工夫されています。プログラムを見て分かるとおり、terminal::Clearやcursor::MoveTo、SetForegroundColorなど分かりやすいメソッドが定義されているので、こうしたメソッドを利用する事で、手軽にターミナルを操作できます。
1文字ずつのキー操作に対応しよう - Rawモード
一般的なターミナルでは、入力状態になったとき、[Enter]キーを押してはじめて入力内容を取得することが可能になります。しかし、今回のような迷路ゲームを作った場合、キャラクターを左に移動するために[h]と[Enter]と2つもキーを押すのはナンセンスです。キーボードのタイプ1回ごとに処理を行う必要があります。そのために使うのが、Rawモードです。
「terminal::enable_raw_mode()」を実行することで、ターミナルをRawモードに切り替えることができます。以下は、[ESC][q][h]の3つのキー操作に対応したプログラムです。
fn main() -> Result<()> {
// Rawモードに変更
terminal::enable_raw_mode()?;
// 無限ループの中でキーを取得する
loop {
// イベントの取得
let event = read()?;
match event {
Event::Key(e) => {
match e.code {
// キーに合わせて処理
KeyCode::Esc => break,
KeyCode::Char('q') => break,
KeyCode::Char('h') => {
execute!(stdout(), cursor::MoveToColumn(0))?;
println!("[push h key]");
}
_ => {}
}
},
_ => {}
}
}
terminal::disable_raw_mode()?;
Ok(())
}
プログラムを実行するには「cargo run」を実行します。すると、キーの入力待ち状態になります。[h]キーを押すと、その度に「push h key」と即座に表示されます。[ESC]か[q]キーを押すとプログラムを終了します。
なお、Rawモードでは、[Ctrl]+[C}キーでの終了も無視してしまうので、終了キーの[q]キーを確認してから実行してください。
迷路ゲームを完成させよう
以上で、迷路ゲームを作るのに必要な要素が揃いました。迷路ゲームを完成させましょう。なお、ちょっと長くなったので、最終的なプログラムをこちらにアップしています。
プログラムの主要部分を抜粋して紹介します。まず、以下は迷路データを管理する構造体を定義して、迷路を生成するプログラムです。穴掘り法という手法で迷路を生成します。
// 壁か通路を表す列挙型
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Cell {
Wall, // 壁
Empty, // 通路
}
// 迷路を表す構造体
pub struct Maze {
pub cols: isize, // 迷路の幅
pub rows: isize, // 迷路の高さ
pub data: Vec<Cell>, // 実際の迷路データ
}
impl Maze {
〜省略〜
// 再帰的に迷路を生成(穴掘り法)
fn generate(&mut self, x: isize, y: isize) {
self.set(x, y, Cell::Empty);
let mut dirs = vec![(1, 0), (-1, 0), (0, 1), (0, -1)];
let mut rng = thread_rng(); // ランダムジェネレータを作成
dirs.shuffle(&mut rng); // 進行方向を選ぶ
for (dx, dy) in dirs {
// 2マス先が範囲外か通路ならスキップ
let (nx, ny) = (x + dx * 2, y + dy * 2);
if !self.in_range(nx, ny) || self.get(nx, ny) == Cell::Empty { continue; }
// 進行方向を掘って、再帰的に迷路を生成
self.set(x + dx, y + dy, Cell::Empty);
self.generate(nx, ny);
}
}
}
迷路座標が範囲内に収まっているかどうかを確認するコードなどを省略していますが、ほぼ全体です。穴掘り法はとても簡単に高精度の迷路を生成する方法です。
続いて、迷路全体をターミナルに出力する部分を確認してみましょう。カーソルを任意の位置に移動して、色などを変えて文字を表示するというクロージャput_chを定義し、これを利用して、左上から右下へと画面を表示します。
// 迷路を表示する
fn display(maze: &Maze, px: isize, py: isize) -> Result<()> {
let put_ch = |x, y, fg, bg, ch| -> Result<()> {
execute!(
stdout(),
cursor::MoveTo(x as u16, y as u16),
SetForegroundColor(fg),
SetBackgroundColor(bg),
Print(ch),
)
};
for y in 0..maze.rows {
for x in 0..maze.cols {
〜省略〜
// マップを描画
match maze.get(x, y) {
Cell::Wall => put_ch(x, y, Color::Black, Color::DarkMagenta, "#")?,
Cell::Empty => put_ch(x, y, Color::Black, Color::Black, " ")?,
}
}
execute!(stdout(), Print("\r\n"), ResetColor)?;
}
Ok(())
}
上記のdisplay関数の引数のpxとpyはプレイヤーの表示座標ですが、プログラムを分かりやすくするために、プレイヤーの表示を省略しています。for文をネストさせて座標を順に表示するだけなので難しい点はないでしょう。