Aller au contenu

Projet : jeu de devinettes en Rust

Voici votre premier vrai projet. L’ordinateur choisit un nombre secret entre 1 et 100. Vous essayez de le deviner. Il vous répond « trop petit » ou « trop grand » jusqu’à ce que vous trouviez, puis il vous dit combien d’essais il vous a fallu.

C’est un petit programme, mais il utilise une bonne partie de ce que vous avez appris jusqu’ici : les variables, match, les boucles et Result. Nous allons le construire une étape à la fois, et chaque étape peut s’exécuter toute seule.

Une fois terminé, une partie sur votre ordinateur ressemblera à ceci :

Devinez le nombre ! (de 1 à 100)
Entrez votre proposition :
50
Trop grand !
Entrez votre proposition :
bonjour
Ce n'est pas un nombre entier. Réessayez !
Entrez votre proposition :
25
Trop petit !
Entrez votre proposition :
37
Vous avez trouvé en 3 essais !

Remarquez deux détails sympathiques. Quand le joueur tape bonjour, le jeu ne plante pas : il redemande. Et cette saisie invalide ne compte pas comme un essai.

Ouvrez un terminal et créez un nouveau projet Cargo :

Terminal window
cargo new guessing_game
cd guessing_game

Tout le code de cette page va dans src/main.rs. Lancez-le quand vous voulez avec :

Terminal window
cargo run

Si vous n’avez pas encore installé Rust, commencez par Débuter avec Rust.

Pour lire ce que tape le joueur, utilisez std::io::stdin() et sa méthode read_line :

use std::io;
fn main() {
println!("Entrez votre proposition :");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Impossible de lire la ligne");
println!("Vous avez proposé : {guess}");
}

Ce programme attend que vous tapiez quelque chose et que vous appuyiez sur Entrée : lancez-le donc avec cargo run sur votre propre machine. (Le Rust Playground n’a pas de saisie au clavier, c’est pour ça qu’il n’y a pas de bouton « Essayer » ici.)

Il se passe plusieurs choses :

  • String::new() crée une String vide, qui peut grandir, pour contenir le texte.
  • read_line(&mut guess) ajoute la ligne tapée à la fin de guess. Elle a besoin de &mut parce qu’elle modifie la chaîne. Voir le borrowing (emprunt) si &mut est nouveau pour vous.
  • read_line renvoie un Result, car la lecture peut échouer. .expect(...) veut dire « si ça échoue, arrête le programme avec ce message ». Ça suffit pour l’instant ; nous gérerons ce cas plus en douceur un peu plus loin.

Une surprise : le texte contient la touche Entrée. Si vous tapez 42, guess contient "42\n". Gardez ça en tête pour l’étape suivante.

Le joueur a tapé du texte, mais nous voulons un nombre que l’on peut comparer. .trim() retire les espaces et le retour à la ligne aux deux extrémités. Ensuite, .parse() essaie de transformer le texte en nombre.

La conversion peut échouer : "sept" n’est pas un nombre. parse() renvoie donc un Result, et nous utilisons match pour gérer les deux cas :

fn main() {
// On fait comme si le joueur avait tapé ces lignes (chacune se termine par Entrée).
let inputs = ["42\n", " 7 \n", "sept\n", "-3\n"];
for input in inputs {
match input.trim().parse::<u32>() {
Ok(num) => println!("{:?} -> le nombre {num}", input),
Err(e) => println!("{:?} -> proposition non valide ({e})", input),
}
}
}

Sortie :

"42\n" -> le nombre 42
" 7 \n" -> le nombre 7
"sept\n" -> proposition non valide (invalid digit found in string)
"-3\n" -> proposition non valide (invalid digit found in string)
▶ Essayez par vous-même

Le message entre parenthèses vient de Rust lui-même, en anglais : « invalid digit found in string » signifie « chiffre non valide trouvé dans la chaîne ». parse::<u32>() indique à Rust vers quel type convertir. u32 est un entier non signé (jamais négatif), c’est pourquoi -3 est refusé. C’est ce que nous voulons : le nombre secret est toujours entre 1 et 100.

Dans le jeu, nous écrivons la même idée comme ceci :

let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Ce n'est pas un nombre entier. Réessayez !");
continue;
}
};

Ici, le type vient de let guess: u32, donc pas besoin de ::<u32>. Si la conversion réussit, le match renvoie le nombre. Si elle échoue, nous affichons un message aimable et continue pour redemander. (continue ne fonctionne qu’à l’intérieur d’une boucle, que nous ajouterons à l’étape 5.)

Pourquoi ne pas simplement utiliser .expect() ? Parce qu’alors une simple faute de frappe ferait planter tout le jeu. Gérer le cas Err, c’est comme ça qu’on écrit un programme agréable à utiliser.

Chaque nombre possède une méthode .cmp(). Elle compare deux valeurs et renvoie un std::cmp::Ordering, un enum à trois variantes : Less (plus petit), Greater (plus grand) et Equal (égal). Un match dessus se lit presque comme une phrase :

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
for guess in [10, 42, 99] {
match guess.cmp(&secret) {
Ordering::Less => println!("{guess} : Trop petit !"),
Ordering::Greater => println!("{guess} : Trop grand !"),
Ordering::Equal => println!("{guess} : Gagné !"),
}
}
}

Sortie :

10 : Trop petit !
42 : Gagné !
99 : Trop grand !
▶ Essayez par vous-même

Vous pourriez écrire la même chose avec if guess < secret ... else if .... L’intérêt de la version match, c’est que le compilateur vérifie que vous avez traité les trois cas. Oubliez-en un, et le code ne compile pas.

Étape 5 : Redemander jusqu’à la bonne réponse

Section intitulée « Étape 5 : Redemander jusqu’à la bonne réponse »

Maintenant, nous enveloppons le tout dans une loop. Elle tourne indéfiniment, jusqu’à ce qu’on en sorte avec break. Nous gardons aussi un compteur, attempts, et nous ne lui ajoutons 1 que si la proposition était un vrai nombre.

Dans le vrai jeu, la saisie vient du clavier. Pour obtenir une version qui fonctionne dans le Playground, nous utilisons un secret fixe et une liste fixe de lignes « tapées ». inputs.next() nous donne la ligne suivante, ou None quand la liste est épuisée :

use std::cmp::Ordering;
fn main() {
let secret: u32 = 37;
// On fait comme si le joueur avait tapé ceci, une ligne à la fois.
let mut inputs = ["50", "bonjour", "25", "37"].into_iter();
let mut attempts = 0;
println!("Devinez le nombre ! (de 1 à 100)");
loop {
let input = match inputs.next() {
Some(line) => line,
None => {
println!("Plus de propositions. Le nombre était {secret}.");
break;
}
};
println!("> {input}");
let guess: u32 = match input.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Ce n'est pas un nombre entier. Réessayez !");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Trop petit !"),
Ordering::Greater => println!("Trop grand !"),
Ordering::Equal => {
println!("Vous avez trouvé en {attempts} essais !");
break;
}
}
}
}

Sortie :

Devinez le nombre ! (de 1 à 100)
> 50
Trop grand !
> bonjour
Ce n'est pas un nombre entier. Réessayez !
> 25
Trop petit !
> 37
Vous avez trouvé en 3 essais !
▶ Essayez par vous-même

Essayez de changer le secret ou la liste des propositions, puis relancez. Mettez la bonne réponse en premier, ou retirez-la, et regardez ce qui se passe.

Le cas None est important. C’est la version Playground de « le joueur n’a plus rien à saisir », et le vrai jeu a besoin de la même idée (voir l’étape 7).

Un jeu avec toujours le même secret n’est pas très amusant. Dans un vrai projet, vous utiliseriez le crate rand, le choix standard pour les nombres aléatoires en Rust. La leçon Nombres aléatoires montre comment l’utiliser. Vous l’ajoutez avec :

Terminal window
cargo add rand

Pour ce projet, nous restons avec la seule bibliothèque standard : vous n’avez rien à installer. L’astuce : demander l’heure actuelle à l’horloge, prendre la partie en nanosecondes (un nombre de 0 à 999 999 999 qui change sans arrêt), et la ramener entre 1 et 100 avec % (le reste de la division) :

use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("l'horloge est réglée avant 1970")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
let secret = secret_number();
let in_range = (1..=100).contains(&secret);
println!("Nombre secret choisi. Est-il entre 1 et 100 ? {in_range}");
}

Sortie :

Nombre secret choisi. Est-il entre 1 et 100 ? true
▶ Essayez par vous-même

nanos % 100 vaut toujours entre 0 et 99, donc en ajoutant 1 on obtient un nombre entre 1 et 100. Nous affichons seulement s’il est dans l’intervalle, pour que la sortie soit la même à chaque fois. Affichez secret lui-même si vous voulez tricher un peu.

Soyons honnêtes sur ce que c’est : c’est « assez imprévisible pour un jeu », pas du vrai hasard. N’utilisez jamais une astuce de ce genre pour des mots de passe, des jetons de sécurité, ou quoi que ce soit que quelqu’un pourrait essayer de deviner. Pour ça, utilisez un vrai crate.

Voici tout rassemblé. C’est cette version qu’il faut mettre dans src/main.rs et lancer avec cargo run pour jouer.

Une nouveauté : nous faisons un match sur le résultat de read_line au lieu d’appeler .expect(). read_line renvoie Ok(0) quand il n’y a plus rien à lire, par exemple si le joueur appuie sur Ctrl+D (Ctrl+Z puis Entrée sous Windows), ou si la saisie arrive d’un fichier par redirection. Sans cette vérification, la boucle tournerait à l’infini sur une saisie vide.

use std::cmp::Ordering;
use std::io;
use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("l'horloge est réglée avant 1970")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
println!("Devinez le nombre ! (de 1 à 100)");
let secret = secret_number();
let mut attempts = 0;
loop {
println!("Entrez votre proposition :");
let mut guess = String::new();
match io::stdin().read_line(&mut guess) {
Ok(0) => {
// Fin de la saisie : plus rien à lire.
println!("Plus rien à lire. Le nombre était {secret}. Au revoir !");
break;
}
Ok(_) => {}
Err(e) => {
println!("Impossible de lire votre saisie : {e}");
break;
}
}
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Ce n'est pas un nombre entier. Réessayez !");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Trop petit !"),
Ordering::Greater => println!("Trop grand !"),
Ordering::Equal => {
println!("Vous avez trouvé en {attempts} essais !");
break;
}
}
}
}

Pour essayer dans le navigateur, utilisez plutôt la version de l’étape 5, qui joue une liste fixe de propositions. 🦀

Oublier mut sur la chaîne. read_line doit modifier la chaîne, elle doit donc être mutable :

use std::io;
fn main() {
let guess = String::new();
io::stdin().read_line(&mut guess).expect("Impossible de lire la ligne");
}
error[E0596]: cannot borrow `guess` as mutable, as it is not declared as mutable
--> src/main.rs:5:27
|
5 | io::stdin().read_line(&mut guess).expect("Impossible de lire la ligne");
| ^^^^^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
4 | let mut guess = String::new();
| +++

Le compilateur dit qu’il ne peut pas emprunter guess de façon mutable, puisqu’elle n’a pas été déclarée mutable. La correction est exactement celle qu’il propose : let mut guess = String::new();.

Comparer du texte avec un nombre. Si vous oubliez la conversion, guess est toujours une String, et on ne peut pas comparer une String avec un u32 :

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
let guess = String::from("50");
match guess.cmp(&secret) {
Ordering::Less => println!("Trop petit !"),
Ordering::Greater => println!("Trop grand !"),
Ordering::Equal => println!("Gagné !"),
}
}
error[E0308]: mismatched types
--> src/main.rs:7:21
|
7 | match guess.cmp(&secret) {
| --- ^^^^^^^ expected `&String`, found `&u32`
| |
| arguments to this method are incorrect

« mismatched types » veut dire que les types ne correspondent pas : la méthode attendait une &String et a reçu un &u32. Rust ne convertit jamais tout seul entre du texte et des nombres. Convertissez d’abord, comparez ensuite.

Oublier .trim(). Ce code-là compile, mais toutes les propositions sont refusées, car le retour à la ligne n’est pas un chiffre :

fn main() {
let typed = "42\n";
println!("{:?}", typed.parse::<u32>());
println!("{:?}", typed.trim().parse::<u32>());
}

Sortie :

Err(ParseIntError { kind: InvalidDigit })
Ok(42)
▶ Essayez par vous-même

Une fois que votre jeu fonctionne, essayez ces améliorations par vous-même. Chacune est petite :

  • Vérifier l’intervalle. Si la proposition vaut 0 ou dépasse 100, affichez « Choisissez un nombre entre 1 et 100 » et ne la comptez pas.
  • Limiter le nombre d’essais. Donnez 7 essais au joueur. S’il les épuise, affichez le secret et terminez la partie.
  • Rejouer. Quand le joueur gagne, demandez « Rejouer ? (o/n) » et lancez une nouvelle manche avec un nouveau secret.
  • Garder le meilleur score. Retenez le plus petit nombre d’essais sur toutes les manches et affichez-le à la fin.
  • Donner des indices plus chaleureux. Affichez « tout près ! » quand la proposition est à 3 ou moins du secret. guess.abs_diff(secret) donne la distance entre deux nombres.
  • Utiliser le vrai crate rand. Lancez cargo add rand et suivez sa documentation sur docs.rs (en anglais) pour remplacer secret_number().
  • Déplacer la logique dans des fonctions. Écrivez fn check_guess(guess: u32, secret: u32) -> Ordering et ajoutez-lui des tests.
  • io::stdin().read_line(&mut s) lit une ligne, retour à la ligne compris, et renvoie un Result. Ok(0) signifie qu’il n’y a plus rien à lire.
  • .trim().parse() transforme du texte en nombre. Cette conversion renvoie un Result : faites un match dessus pour traiter gentiment les saisies invalides.
  • a.cmp(&b) renvoie Ordering::Less, Greater ou Equal, et match vous oblige à traiter les trois.
  • loop, avec continue et break, fait tourner le jeu jusqu’à ce que le joueur gagne.
  • L’astuce de l’horloge suffit pour un jeu, mais les vrais projets utilisent le crate rand.

Petit quiz

1. Le joueur tape 42 puis appuie sur Entrée. Que contient la chaîne remplie par read_line ?

2. Pourquoi le jeu fait-il un match sur parse() au lieu d'utiliser .expect() ?

3. Que signifie un Ok(0) renvoyé par read_line ?

4. Quelles valeurs guess.cmp(&secret) peut-elle renvoyer ?

Score : 0 / 4