よしたかの日常

日常の事やイラスト投稿、開発している時の備忘録を書いています。

Rust でババ抜き — ターミナル演出で CLI ゲームを「遊べる」見た目にする

おはこんばんにちは!

1回目の記事 で CPU の心理戦、前回の記事 でシャッフルと難易度設計を書きました。
今回が シリーズ最終回 です。テーマは CLI の見た目とテンポ

ロジックだけ動けばゲームにはなるけれど、ターミナルゲームは 表示がすべて です。
old_maid では、外部クレートを組み合わせて「なんとなく遊べる感じ」を作っています。

figlet-rs の ASCII アートタイトル。シアン太字で着色されている状態。


使っているクレート

Cargo.toml の dependencies はシンプルです。

clap = { version = "4.6.1", features = ["derive"] }
console = "0.16.3"
figlet-rs = "1.0.0"
indicatif = "0.18.4"
rand = "0.8.5"
rustyline = "18.0.0"
クレート 役割
figlet-rs 起動時の ASCII アートタイトル
console 色付きテキスト・太字・薄字
indicatif スピナー・プログレスバー
rustyline 行入力(Enter 確定・履歴対応)
clap コマンドライン引数(--mode maid/man

GUI フレームワークは使っていません。
ターミナル向けクレートの組み合わせ だけで UI を構成しています。


表示の設計 — 色で「誰の情報か」を伝える

src/cli/console.rs に表示用の関数を集約しています。

pub fn system(prompt: &str) { /* 緑 */ }
pub fn system_bold(prompt: &str) { /* 緑・太字 */ }
pub fn info(prompt: &str) { /* シアン */ }
pub fn error(prompt: &str) { /* 赤 */ }
関数 用途
system / system_bold ゲーム進行のシステムメッセージ
info シアン 確認・お知らせ
error 入力エラー

ターン表示は 人間と CPU で色を分け ています。

fn player_color(is_human: bool) -> Style {
    if is_human {
        Style::new().green()
    } else {
        Style::new().magenta()
    }
}

Round: 1 / Turn: 5 / Player (Human) が緑、 CPU 2 (Veteran) がマゼンタ — 一目で「誰のターンか」がわかります。

その他の色使い:

  • 黄色 — 上がり通知(cleard)、ラウンドリザルトのタイトル
  • 薄字(dim) — 総合リザルトのサブ見出し
  • 黄色太字Total Game Result のタイトル

色のルールを決めておくと、後からメッセージを追加しても 見た目が散らからない です。


区切り線 — 画面の「章立て」

pub fn print_single_separator() { print_hr('-', 50); }
pub fn print_double_separator() { print_hr('=', 50); }

- × 50 が小さな区切り、= × 50 が大きな区切りです。

main.rs では、設定入力のたびに print_single_separator、 ラウンド開始前後に print_double_separator を使い分けています。

ターミナルにはウィンドウ枠がないので、 区切り線 = 章タイトル の代わりにしています。


figlet-rs — 最初のインパクト

起動直後に ASCII アートのタイトルを出します。

let style = Style::new().cyan().bold();
let standard_font = FIGlet::standard().unwrap();
print!(
    "{}",
    style.apply_to(
        standard_font.convert(&format!("Old {}", capitalize(&args.mode))).unwrap()
    )
);

--mode maid なら Old Maid--mode man なら Old Man と表示が変わります。
clap で受け取った引数をそのままタイトルに反映する、小さな連携です。

ゲームの中身より先に 「何のアプリか」 を伝える役割です。


indicatif — 待ち時間を「見せる」

スピナー — 処理中

シャッフルや CPU のカード選択など、一瞬で終わる処理を包みます。

pub fn execute_with_spinner<T, F>(set_message: &str, finish_message: &str, f: F) -> T
where
    F: FnOnce() -> T,
{
    let pb = mult.add(ProgressBar::new_spinner());
    pb.enable_steady_tick(Duration::from_millis(100));
    pb.set_style(
        ProgressStyle::with_template("{spinner:.green} {msg}")
            .unwrap()
            .tick_strings(&["|", "/", "-", "\\"]),
    );
    pb.set_message(set_message.to_string());

    let result = f();
    // finish_message が空ならバーを消す
    ...
    result
}

Deck setup and CPU 1 a shuffle... のように、誰が何をしているか をメッセージに入れています。

▲ 起家 CPU が山札を切っている間のスピナー。

プログレスバー — 進捗が数値でわかる処理

配札は 1 枚ずつ進むので、プログレスバー向きです。

execute_with_progress(deck_len as u64, "Deal the cards.", "Deal the cards end.", |pb| {
    for i in 0..deck_len {
        // 1 枚配る
        pb.inc(1);
        thread::sleep(Duration::from_millis(rand_range(10..=100)));
    }
});

テンプレート:

{msg} [{wide_bar:.green}] {pos}/{len} ({eta})

{eta} で残り時間の目安も出ます(2 本目で書いた thread::sleep とセット)。

MultiProgress — 全員同時にスピナー

配札後の手札整理は、全プレイヤー分のスピナーを並べて表示 します。

fn set_progress_spinners(players: &mut Vec<Player>) -> Vec<ProgressBar> {
    let multi = MultiProgress::new();
    for player in players.iter() {
        let pb = multi.add(ProgressBar::new_spinner());
        pb.set_message(format!("{} Arrange my Hand (pair off)...", player.get_name()));
        spinners.push(pb);
    }
    spinners
}

thread::scope で各プレイヤーのペア捨てを並列実行しつつ、 スピナーも同時に回る — 「全員が手札を整理している」 雰囲気が出ます。

Player / CPU 1 / CPU 2 … のスピナーが縦に並んでいる状態。


rustyline — 入力を「ゲームっぽく」

数値入力は rustyline で行っています。

fn read_line(prompt: &str) -> rustyline::Result<String> {
    let styled_prompt = Style::new()
        .apply_to(format!("[INPUT] {} >> ", prompt))
        .to_string();
    let mut rl = rustyline::DefaultEditor::new()?;
    rl.readline(&styled_prompt)
}

プロンプトは [INPUT] CPU Player Num ... >> の形式。
Enter だけ押せば デフォルト値 が使われます。

if line.is_empty() {
    Ok(default)
} else {
    Ok(line.parse::<usize>()?)
}

範囲外の入力はループで再入力を促します。

Ok(num) if (min_num..=max_num).contains(&num) => break num,
Ok(_) => error(&format!("The input is not a number {}-{}.", min_num, max_num)),

Ctrl+C / Ctrl+D も明示的に処理しています。

Err(ReadlineError::Interrupted) => {
    system_bold("Pressing Ctrl+C. Ends the Game.");
    std::process::exit(0);
}

ターミナルゲームでは 突然の終了 より、メッセージを出してから終わる方が親切です。

[INPUT] 付きのプロンプト。CPU 人数や難易度を選んでいる画面。


テンポ調整 — wait_for_dramatic_pause

lib.rs に 200ms の待機関数があります。

pub fn wait_for_dramatic_pause() {
    std::thread::sleep(std::time::Duration::from_millis(200));
}

main.rs では設定確認のたびに呼んでいます。

let cpu_member = cpu_member_input();
info(&format!("  Confirmed: {} CPU Players Joining...", cpu_member));
wait_for_dramatic_pause();

入力 → 確認表示 → 200ms 待ち → 次の入力、というリズムです。
情報が一気に流れすぎないようにする 演出用の pause です。

シャッフル内の thread::sleep(2 本目)と役割が似ていますが、 こちらは メインループの UI テンポ 専用です。


clap — 起動オプション

#[derive(Parser)]
struct Args {
    #[arg(short, long, default_value = "maid")]
    mode: String,
}
cargo run -- --mode maid   # ババ抜き(ジョーカー追加)
cargo run -- --mode man    # ジジ抜き(ミステリーカード除外)

-- 以降がバイナリに渡る点は、Rust 初学者がつまずきやすいところなので README にも明記しています。


リザルト画面 — 色のレイヤー

総合リザルト(game_result)は、色で情報の 重要度 を分けています。

// サブ見出し — 薄字
Style::new().dim().apply_to("各ラウンドの獲得ポイント")

// メインタイトル — 黄色太字
Style::new().yellow().bold().apply_to("Total Game Result")

// 順位 — 緑
Style::new().green().apply_to("総合順位(同点は参加順)")

メダル絵文字(🥇🥈🥉)は constants.rs で定義し、 ラウンドリザルトと総合順位の両方で使い回しています。

1 本目で書いた ジョーカーボーナス の表示も、 この dim / green / yellow のルールに乗っています。


設計で意識したこと

1. 表示ロジックを cli/console.rs に集約

ゲーム本体(game.rs)からは system()execute_with_spinner() を呼ぶだけ。
「何を表示するか」「どう表示するか」 を分離しています。

2. スピナーと sleep はセット

indicatif だけ入れても、処理が一瞬ならスピナーは見えません。
2 本目で書いたシャッフル内の sleep と、配札のランダム sleep は、 演出が見えるための前提 です。

3. CLI でも「フェーズ」がわかるように

起動 → 設定 → サイコロ → ラウンド → リザルト。
各区切りで separator・色・pause を変えることで、 今どのフェーズにいるか が伝わるようにしました。


シリーズ振り返り

3 記事で old_maid の設計を分解しました。

テーマ キーワード
1 本目 CPU の心理戦 履歴、joker_in_history_taken、難易度別の隠し方
2 本目 シャッフルと CPU ヒンズー / リフル / ディール、CpuStrategy、6 スロット抽選
3 本目(今回) CLI 演出 console / indicatif / figlet-rs / rustyline

ゲームロジック・AI・演出 — ババ抜き 1 本から、Rust の勉強題材として 切り口を 3 つ 取れたのがこのシリーズのまとめです。


動かしてみる

git clone https://github.com/yoshitaka-k/old_maid.git
cd old_maid
cargo run

演出を意識して観察するポイント:

  1. 起動時の FIGlet タイトルと --mode の連動
  2. 配札後の 複数スピナー が同時に回る様子
  3. 人間ターン(緑)と CPU ターン(マゼンタ)の色の違い
  4. 複数ラウンド後の Total Game Result の色分け

まとめ

  • console色のルール を決め、情報の種類とプレイヤー種別を視覚化した
  • indicatifスピナー・プログレスバー・MultiProgress を使い分けた
  • figlet-rs で起動時の 第一印象 を作った
  • rustyline入力・デフォルト値・終了処理 を整えた
  • wait_for_dramatic_pause と separator で テンポと章立て を調整した

ターミナルゲームは画面がない分、テキストと時間の使い方がすべてです。 old_maid くらいの規模でも、クレートをいくつか組み合わせるだけで だいぶ違う ので、CLI ゲームを作るときの参考になれば幸いです。


参考リンク