🎯

今日の個人開発1~RustでCLIゲームを作ろう~

2023/02/15に公開

はじめに

この記事は、coloname33が不定期で行う小規模個人開発の記録です。

本日のテーマ

「RustでCLIゲームを作ろう」

環境

Rust

% cargo --version
cargo 1.67.1 (8ecd4f20a 2023-01-10)

作るもの

hit&blowが遊べるCLIを作ります。

参考記事:https://www2.denshi.numazu-ct.ac.jp/student/lecture97/d2/info/hit_and_blow/hb.html

hit&blow のルール

  1. 出題者は、0から9までの互いに異なる数字を使った 4桁の数を正解として用意し、解答者から隠しておく。
  2. 解答者は、解を予想し、質問をする。
  3. 出題者は、質問と正解の数を比較して、Hit数  同じ数字が同じ桁に出現する回数Blow数  同じ数字が違う桁に出現する回数をヒントとして解答者に与える。
  4. 解答者はなるべく少ない質問で正解を当てる。

機能

  • コマンド実行時、1000以上の4桁のランダムな数字列を設定
  • 標準入力で4桁の数字を受け取って、hit, blowの数を返す
    • フォーマットにそぐわない場合、もう一度入力してもらう
  • 正解したら、コマンドが終了する

実装

答えの生成

答えや回答は、u8の配列で持つことにします。

https://uma0317.github.io/rust-cookbook-ja/algorithms/randomness.html

を参考に、以下のように生成しました。

extern crate rand;
use rand::Rng;

struct Answer {
    answer: Vec<u8>,
}

impl Answer {
    fn new(rng: &mut ThreadRng) -> Self {
        let mut ans = vec![];
        for _ in 0..4 {
            ans.push(rng.gen_range(0..10));
        }
        return Self { answer: ans };
    }
}

fn main() {
    let mut rng = rand::thread_rng();
    let answer = Answer::new(&mut rng);
}

標準入力で質問を受け取る

指示のあと改行を挟まずに標準入力を受け取りたいため、flashを挟み以下のように受け取ります。

参考:https://ytyaru.hatenablog.com/entry/2020/07/26/000000

  fn main() {
    print!("4桁の数字を入力してください: ");
    io::stdout().flush().unwrap();
    let mut guess = String::new();
    io::stdin()
        .read_line(&mut guess)
        .expect("入力エラー。read_line()で失敗しました。");
}

なお、ここで受け取った値は String型であるため、答えと合わせて Vec<u8>に変換する型 Guessを作成します。

struct Guess {
    guess: Vec<u8>,
}

impl Guess {
    fn new(guess: String) -> Self {
        let mut guess_vec = vec![];

        for c in guess.trim().chars() {
            guess_vec.push(c.to_digit(10).unwrap() as u8)));
        }

        Guess { guess: guess_vec }
    }
}

質問と答えを比較

答えのhit, blowを返すためのCheckResultを作成しました。

表示のためのメソッドfmtも定義しました。

struct CheckResult {
    hit: u8,
    blow: u8,
}

impl fmt::Display for CheckResult {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Result: {}hit, {}blow", self.hit, self.blow)
    }
}

これを使って実際に判定するcheck関数を以下のように実装します。


impl Answer {
    fn check(self, question: Vec<u8>) -> CheckResult {
        let mut hit = 0;
        let mut blow = 0;

        for (idx, &val) in question.iter().enumerate() {
            if let Some(ansIdx) = self.answer.iter().position(|&ans| ans == val) {
                if ansIdx == idx {
                    hit += 1;
                } else {
                    blow += 1;
                }
            }
        }

        CheckResult {
            hit: hit,
            blow: blow,
        }
    }
}

このメソッドを使って、実際に比較してみます。

let mut guess = Guess.new(guess);
let mut result = answer.check(guess.guess);
println!("{}", result);

ゲームの進行と終了処理

このゲームは、正解するまで数字の入力を繰り返します。

そこで、これらの処理をloop内部に入れてみましょう。

fn main() {
    loop {
        print!("4桁の数字を入力してください: ");
        io::stdout().flush().unwrap();
        let mut guess = String::new();
        io::stdin()
            .read_line(&mut guess)
            .expect("入力エラー。read_line()で失敗しました。");

        let guess = Guess::new(guess);
        let result = answer.check(guess.guess);
        println!("{}", result);
    }
}

これだけでは無限にループしてしまうので、終了処理を実装しましょう。

方針は、CheckResultに正解かどうかを判定する correct関数を定義し、loopの最後でそれを実行するようにします。

impl CheckResult {
    fn correct(&self) -> bool {
        self.hit == 4 && self.blow == 0
    }
}

fn main() {
    loop {
        if result.correct() {
            println!("Congratulations!!");
            break;
        }
    }
}

これで完成!

エラーハンドリング

これで一通りの処理は実現できました。

しかし、現在の実装だと、適当な文字列や長さの違う数字を入れた場合、クラッシュしてしまう問題があります。

今回は、こういった例外に対して、もう一度入力を促せるように改良しましょう。

これらのエラーが発生する箇所でResult型を使ってStringのエラーを返すように変更しました。

impl Guess {
    fn new(guess: String) -> Result<Self, String> {
        let mut guess_vec = vec![];

        for c in guess.trim().chars() {
            match c.to_digit(10) {
                Some(d) => guess_vec.push(d as u8),
                None => return Err(format!("数字として解釈できない文字があります。c={}", c)),
            };
        }

        Ok(Guess { guess: guess_vec })
    }
}

impl Answer {
    fn check(&self, guess: Vec<u8>) -> Result<CheckResult, String> {
        let mut hit = 0;
        let mut blow = 0;

        if self.answer.len() != guess.len() {
            return Err(format!(
                "長さが間違っています。ans={}, guess={}",
                answer.0.len(),
                guess.0.len()
            ));
        }

        for (idx, &val) in guess.iter().enumerate() {
            if let Some(ans_idx) = self.answer.iter().position(|&ans| ans == val) {
                if ans_idx == idx {
                    hit += 1;
                } else {
                    blow += 1;
                }
            }
        }

        Ok(CheckResult {
            hit: hit,
            blow: blow,
        })
    }
}

これにより、main関数内の受け取り側でも、Result型を展開して中身を取り出すことができるように変える必要があります。

fn main() {
    loop {
        let guess = match Guess::new(guess) {
            Ok(guess) => guess,
            Err(e) => {
                println!("{}", e);
                println!("もう一度入力してください。");
                continue;
            }
        };

        let result = match answer.check(guess.guess) {
            Ok(result) => result,
            Err(e) => {
                println!("{}", e);
                println!("もう一度入力してください。");
                continue;
            }
        };
    }
}

これでようやく完成しました!

全体のソースコードは以下になります。

https://github.com/kyuki3rain/hit-and-blow-rust/

最後に

今回はrustでhit&blowというゲームが遊べるCLIを作成しました。

続きはこちら:
今日の個人開発2~Rust製CLIゲームを改良しよう~

記事については、もっと読みやすくなるような修正を適宜していくつもりです。

実装こっちの方がいいよ!などあればコメントください。

Discussion