2
\$\begingroup\$

I have been learning Rust for a few days (This is my third) now, and I've really fallen in love with the language, this is my first real project, Hangman. I was wondering what I could do better / what is currently fine.

lib.rs:

use rand::Rng;
use std::fs;
use std::io;

#[derive(PartialEq)] // im not even sure what this does but it wants me to add it.
#[derive(Debug)]
pub enum GuessResult {
    CORRECT,
    INCORRECT,
    TAKEN,
    INVALID,
}

pub enum WinState {
    WIN,
    LOSE,
    NIL,
}

#[derive(Debug)]
pub struct Hangman {
    pub guesses: Vec<char>,
    pub correct_guesses: Vec<char>,
    pub incorrect_guesses: Vec<char>,
    pub incorrect_threshold: i32, // how many we can get wrong.
    pub word: Word,
}

// Platform generously supplied on github by Chris Horton
// You can find it here: https://gist.github.com/chrishorton/8510732aa9a80a03c829b09f12e20d9c
const PLATFORM: [&str; 7] = [
    "
  +---+
  |   |
      |
      |
      |
      |
========= 
",
    "
  +---+
  |   |
  O   |
      |
      |
      |
========= 
",
    "

  +---+
  |   |
  O   |
  |   |
      |
      |
========= 
",
    "

  +---+
  |   |
  O   |
 /|   |
      |
      |
========= 

",
    "

  +---+
  |   |
  O   |
 /|\\ | 
      |
      |
========= 
",
    "
  +---+
  |   |
  O   |
 /|\\  |
 /    |
      |
========= 

",
    "

  +---+
  |   |
  O   |
 /|\\ |
 / \\ |
      |
=========
",
];

impl Hangman {
    pub fn new(filename: String) -> Hangman {
        Hangman {
            guesses: vec![],
            correct_guesses: vec![],
            incorrect_guesses: vec![],
            incorrect_threshold: 6,
            word: get_random_word(filename),
        }
    }

    /**

    Take a guess from the user, either a full string or one character at once. If the user has already entered it, we try again.
    */
    pub fn take_turn(&mut self) -> WinState {
        let mut input = String::new();

        io::stdin()
            .read_line(&mut input)
            .expect("Something went wrong while reading your line");
        input = input.trim().to_string();
        if input == "" {
            // no value entered.
            return WinState::NIL; 
        }

        let guessed_char: char = input.chars().collect::<Vec<char>>()[0];
        let result = match input.len() {
            0 => GuessResult::INVALID,
            1 => self.check_char(guessed_char),
            _ => self.check_str(input),
        };
        self.guesses.push(guessed_char);

        match result {
            GuessResult::CORRECT => {
                // dbg.
                // println!("Added correct.");
                self.correct_guesses.push(guessed_char);
            }
            GuessResult::INCORRECT => {
                println!("Incorrect.");
                self.incorrect_guesses.push(guessed_char);
            }
            _ => return WinState::NIL,
        };

        return if self.incorrect_guesses.len() >= self.incorrect_threshold as usize {
            WinState::LOSE
        } else if self
            .word
            .word // it works.
            // by removing all the characters that are correct from the words chars, so if the len is 0, we have all the correct ones.
            .chars()
            .filter(|x| !self.correct_guesses.contains(x))
            .collect::<Vec<_>>()
            .len()
            == 0
        {
            WinState::WIN
        } else {
            WinState::NIL
        };
    }

    /*
        Get what the current game looks like graphically.
    */
    pub fn get_print_state(&self) -> String {
        let plat = self.get_platform_state();

        let mut underscores: String = (0..self.word.length).fold(String::new(), |b, _| b + "_");

        let correct_guesses: String = (&self.correct_guesses).into_iter().collect();

        for c in correct_guesses.chars() {
            for (i, c2) in self.word.word.chars().enumerate() {
                if c == c2 {
                    underscores.replace_range(i..i + 1, &String::from(c));
                }
            }
        }

        return format!(
            "
{}

{}              {}
",
            plat,
            underscores,
(&self.incorrect_guesses).into_iter().collect::<String>()
        );
    }
    fn get_platform_state(&self) -> String {
        PLATFORM[self.incorrect_guesses.len()].to_string()
    }

    /**
        To be called whenever a CHARACTER is guessed.
    */
    fn check_char(&self, what: char) -> GuessResult {
        if self.guesses.contains(&what) {
            return GuessResult::TAKEN;
        } else {
            return if self.word.contains(what) {
                GuessResult::CORRECT
            } else {
                GuessResult::INCORRECT
            };
        }
    }
    /**
        To be called whenever a full string is guessed.
    */
    fn check_str(&self, str: String) -> GuessResult {
        if str
            .chars()
            .all(|x| self.check_char(x) != GuessResult::INCORRECT)
        // we loop through all the chars and check against them.
        {
            GuessResult::CORRECT
        } else {
            GuessResult::INCORRECT
        }
    }
}
#[derive(Debug)]
pub struct Word {
    pub word: String,
    pub length: usize,
}

impl Word {
    pub fn new(word: String) -> Word {
        Word {
            word: word.to_string(),
            length: word.len(),
        }
    }

    pub fn contains(&self, letter: char) -> bool {
        return self.word.contains(letter);
    }
}

pub fn get_random_word(filename: String) -> Word {
    let str = fs::read_to_string(filename).expect("Something went wrong when reading the file.");
    let strings: Vec<&str> = str.split("\n").collect();
    let choice = rand::thread_rng().gen_range(0..strings.len());

    Word {
        word: strings[choice].to_string(),
        length: strings[choice].len(),
    }
}

main.rs:

use hangman::Hangman;
fn main() {
    let mut game = Hangman::new("./words.txt".to_string());

    dbg!(&game);
    loop {
        
        println!("{}", game.get_print_state());
        match &game.take_turn() {
            hangman::WinState::WIN => {
                println!("YOU WIN!");
                break;
            }
            hangman::WinState::LOSE => {    
            println!("{}", game.get_print_state());
                println!("You lost :( \nThe word was: {}", game.word.word);
                
                break;
            }
            _ => (),
        };
    }
}

words.txt: This is a 500,000 line file of words generated from https://random-word-api.herokuapp.com/all

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

API considerations

The way your API is currently structured it is a bit ... irritating to separate user-interaction from game logic. This comes down to the fact that you handle all user-interaction except the final result within take_turn. This makes for a strange mix of user interaction and logic that would make it horrendously difficult to change the user interaction to a graphical interface.

Instead of exposing a take_turn it seems more appropriate for a Hangman instance to expose a guess and an additional is_finished or game_result or something like that which gives the external caller access to what take_turn would return.

With that said (and because that'd be a bit of a major overhaul), here's some things that could be changed in the current code without completely changing it.

Optional is a thing

Currently you have a tri-state enum with that WinState. While this is pretty nice, it's more idiomatic to encode the WinState::NIL as Option::None. Of course that would require adjusting the match expression in main.rs to traverse the Some, but getting familiar with Option should be very useful for future interactions with more complex results.

check_str is more forgiving than usual

The way check_str is implemented it currently allows guessing partial words. Usually when a full word is submitted as a guess, it is only correct when it's actually the hidden word. Instead of checking whether all chars in the guess are also in the word, you should be able to just check equality with std::string#eq_ignore_ascii_case.

And some bullet points:

  • get_random_word could use Word::new instead of duplicating that constructor
  • PartialEq is used to implement != for GuessResult (used in check_str)
  • You could reformulate the check in check_str with any to short-circuit it, when the guess is incorrect. As it is that check is \$O(n^2)\$.
  • I really like the differentiation between INVALID and TAKEN guesses, exposing that and making the user-interaction more responsive to that (while keeping the API considerations in mind) could be a very rewarding extension of this.
  • It's highly non-obvious that incorrect_threshold is related to the PLATFORM's length. You probably want to tie these together to avoid breaking things.
  • I have a suspicion that the way you build underscores could be simplified by iterating over self.word.word and building from that, but I'm not proficient enough to write that off the cuff...
\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.