Перейти к содержимому

Проект: игра «Угадай число» на Rust

Это твой первый настоящий проект. Компьютер загадывает секретное число от 1 до 100. Ты угадываешь. Он отвечает «слишком мало» или «слишком много», пока ты не попадёшь, а потом говорит, сколько попыток тебе понадобилось.

Программа небольшая, но в ней собрано многое из того, что ты уже знаешь: переменные, match, циклы и Result. Будем строить её по шагу за раз, и каждый шаг можно запустить отдельно.

Когда закончишь, игра на твоём компьютере будет выглядеть так:

Угадай число! (от 1 до 100)
Введи свою догадку:
50
Слишком много!
Введи свою догадку:
привет
Это не целое число. Попробуй ещё раз!
Введи свою догадку:
25
Слишком мало!
Введи свою догадку:
37
Угадал! Попыток: 3

Обрати внимание на две приятные детали. Когда игрок вводит привет, игра не падает, а просто спрашивает снова. И такой неверный ввод не считается попыткой.

Открой терминал и создай новый проект Cargo:

Terminal window
cargo new guessing_game
cd guessing_game

Весь код с этой страницы пишется в src/main.rs. Запустить его можно в любой момент командой:

Terminal window
cargo run

Если Rust у тебя ещё не установлен, сначала загляни в урок Начало работы.

Чтобы прочитать то, что вводит игрок, используй std::io::stdin() и его метод read_line:

use std::io;
fn main() {
println!("Введи свою догадку:");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочитать строку");
println!("Ты ввёл: {guess}");
}

Эта программа ждёт, пока ты что-нибудь напечатаешь и нажмёшь Enter, поэтому запускай её через cargo run на своём компьютере. (В Rust Playground нет ввода с клавиатуры, поэтому кнопки «Попробуй сам» здесь нет.)

Что здесь происходит:

  • String::new() создаёт пустую строку String, которая умеет расти, — в неё попадёт текст.
  • read_line(&mut guess) дописывает введённую строку в конец guess. Ему нужен &mut, потому что он меняет строку. Если &mut для тебя в новинку, загляни в урок Заимствование.
  • read_line возвращает Result, потому что чтение ввода может не получиться. .expect(...) означает: «если не вышло, останови программу с этим сообщением». Пока этого достаточно; позже мы обработаем ошибку мягче.

Один сюрприз: в текст попадает и нажатие Enter. Если ввести 42, в guess окажется "42\n". Запомни это — пригодится на следующем шаге.

Игрок ввёл текст, а нам нужно число, которое можно сравнивать. .trim() убирает пробелы и перевод строки с обоих концов. Затем .parse() пытается превратить текст в число.

Разбор (parsing) может не удаться: "семь" — это не число. Поэтому parse() возвращает Result, и мы обрабатываем оба случая через match:

fn main() {
// Представим, что игрок ввёл эти строки (каждая заканчивается Enter).
let inputs = ["42\n", " 7 \n", "семь\n", "-3\n"];
for input in inputs {
match input.trim().parse::<u32>() {
Ok(num) => println!("{:?} -> число {num}", input),
Err(e) => println!("{:?} -> не подходит ({e})", input),
}
}
}

Вывод:

"42\n" -> число 42
" 7 \n" -> число 7
"семь\n" -> не подходит (invalid digit found in string)
"-3\n" -> не подходит (invalid digit found in string)
▶ Попробуй сам

parse::<u32>() говорит Rust, в какой тип разбирать текст. u32 — беззнаковое (никогда не отрицательное) целое число, поэтому -3 отклоняется. Нам это и нужно: секрет всегда от 1 до 100. Текст ошибки в скобках («в строке найдена недопустимая цифра») печатает сам Rust, поэтому он на английском.

В игре мы запишем ту же идею так:

let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Это не целое число. Попробуй ещё раз!");
continue;
}
};

Здесь тип берётся из let guess: u32, так что ::<u32> не нужен. Если разбор удался, match возвращает число. Если нет — печатаем доброе сообщение и через continue спрашиваем снова. (continue работает только внутри цикла, а цикл мы добавим на шаге 5.)

Почему бы просто не вызвать .expect()? Тогда любая опечатка роняла бы всю игру. Обработка случая Err — вот как получается программа, которой приятно пользоваться.

У каждого числа есть метод .cmp(). Он сравнивает два значения и возвращает std::cmp::Ordering — перечисление с тремя вариантами: Less (меньше), Greater (больше) и Equal (равно). match по нему читается почти как обычная фраза:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
for guess in [10, 42, 99] {
match guess.cmp(&secret) {
Ordering::Less => println!("{guess}: Слишком мало!"),
Ordering::Greater => println!("{guess}: Слишком много!"),
Ordering::Equal => println!("{guess}: Победа!"),
}
}
}

Вывод:

10: Слишком мало!
42: Победа!
99: Слишком много!
▶ Попробуй сам

То же самое можно написать через if guess < secret ... else if .... Но у варианта с match есть плюс: компилятор проверяет, что ты обработал все три случая. Забудешь один — код не скомпилируется.

Теперь обернём всё в loop. Он крутится бесконечно, пока мы не выйдем из него через break. Ещё заведём счётчик attempts и будем увеличивать его на 1 только тогда, когда догадка была настоящим числом.

В настоящей игре ввод идёт с клавиатуры. Чтобы получить версию, которая работает в Playground, возьмём фиксированный секрет и фиксированный список «введённых» строк. inputs.next() даёт следующую строку или None, когда список закончился:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 37;
// Представим, что игрок ввёл это, по одной строке.
let mut inputs = ["50", "привет", "25", "37"].into_iter();
let mut attempts = 0;
println!("Угадай число! (от 1 до 100)");
loop {
let input = match inputs.next() {
Some(line) => line,
None => {
println!("Догадки закончились. Это было число {secret}.");
break;
}
};
println!("> {input}");
let guess: u32 = match input.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Это не целое число. Попробуй ещё раз!");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Слишком мало!"),
Ordering::Greater => println!("Слишком много!"),
Ordering::Equal => {
println!("Угадал! Попыток: {attempts}");
break;
}
}
}
}

Вывод:

Угадай число! (от 1 до 100)
> 50
Слишком много!
> привет
Это не целое число. Попробуй ещё раз!
> 25
Слишком мало!
> 37
Угадал! Попыток: 3
▶ Попробуй сам

Попробуй поменять секрет или список догадок и запусти снова. Поставь правильный ответ первым или вообще убери его — и посмотри, что будет.

Случай None важен. Это Playground-версия ситуации «у игрока больше нет ввода», и настоящей игре нужна та же идея (см. шаг 7).

Игра, в которой каждый раз один и тот же секрет, быстро надоест. В настоящем проекте ты взял бы крейт rand (на английском) — это стандартный выбор для случайных чисел в Rust. Как им пользоваться, показано в уроке Случайные числа. Добавляется он так:

Terminal window
cargo add rand

В этом проекте мы обойдёмся только стандартной библиотекой, чтобы ничего не настраивать. Хитрость такая: спрашиваем у часов текущее время, берём из него наносекунды (число от 0 до 999 999 999, которое постоянно меняется) и загоняем его в диапазон 1–100 с помощью % (остатка от деления):

use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("часы выставлены раньше 1970 года")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
let secret = secret_number();
let in_range = (1..=100).contains(&secret);
println!("Секретное число выбрано. Оно от 1 до 100? {in_range}");
}

Вывод:

Секретное число выбрано. Оно от 1 до 100? true
▶ Попробуй сам

nanos % 100 всегда от 0 до 99, так что после прибавления 1 получается от 1 до 100. Мы печатаем только то, попадает ли число в диапазон, поэтому вывод каждый раз одинаковый. Хочешь подсмотреть — выведи сам secret.

Будь честен с собой: это «достаточно непредсказуемо для игры», но не настоящая случайность. Никогда не используй такой трюк для паролей, токенов безопасности и всего, что кто-то может попытаться угадать. Для этого бери нормальный крейт.

Вот всё вместе. Эту версию положи в src/main.rs и играй через cargo run.

Одна новинка: мы делаем match по результату read_line, а не вызываем .expect(). read_line возвращает Ok(0), когда ввод закончился: например, если игрок нажал Ctrl+D (в Windows — Ctrl+Z, а затем Enter) или если ввод подан из файла через конвейер (pipe). Без этой проверки цикл бесконечно крутился бы на пустом вводе.

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("часы выставлены раньше 1970 года")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
println!("Угадай число! (от 1 до 100)");
let secret = secret_number();
let mut attempts = 0;
loop {
println!("Введи свою догадку:");
let mut guess = String::new();
match io::stdin().read_line(&mut guess) {
Ok(0) => {
// Конец ввода: читать больше нечего.
println!("Ввод закончился. Это было число {secret}. Пока!");
break;
}
Ok(_) => {}
Err(e) => {
println!("Не удалось прочитать ввод: {e}");
break;
}
}
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Это не целое число. Попробуй ещё раз!");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Слишком мало!"),
Ordering::Greater => println!("Слишком много!"),
Ordering::Equal => {
println!("Угадал! Попыток: {attempts}");
break;
}
}
}
}

Чтобы попробовать игру в браузере, возьми версию из шага 5 — она проигрывает фиксированный список догадок. 🦀

Забыть mut у строки. read_line должен менять строку, поэтому она обязана быть изменяемой:

use std::io;
fn main() {
let guess = String::new();
io::stdin().read_line(&mut guess).expect("Failed to read line");
}
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("Failed to read line");
| ^^^^^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
4 | let mut guess = String::new();
| +++

Компилятор говорит: «нельзя заимствовать guess как изменяемую, потому что она не объявлена изменяемой». Исправление — ровно то, что он предлагает: let mut guess = String::new();. (Строка в expect здесь оставлена на английском, потому что компилятор цитирует эту строку кода в сообщении.)

Сравнивать текст с числом. Если забыть про разбор, guess так и останется String, а String нельзя сравнить с u32:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
let guess = String::from("50");
match guess.cmp(&secret) {
Ordering::Less => println!("Слишком мало!"),
Ordering::Greater => println!("Слишком много!"),
Ordering::Equal => println!("Победа!"),
}
}
error[E0308]: mismatched types
--> src/main.rs:7:21
|
7 | match guess.cmp(&secret) {
| --- ^^^^^^^ expected `&String`, found `&u32`
| |
| arguments to this method are incorrect

Это ошибка «несовпадение типов»: ожидалась &String, а пришёл &u32. Rust никогда сам не превращает текст в числа и обратно. Сначала разбери строку, потом сравнивай.

Забыть .trim(). Такой код компилируется, но любая догадка отклоняется, потому что перевод строки — не цифра:

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

Вывод:

Err(ParseIntError { kind: InvalidDigit })
Ok(42)
▶ Попробуй сам

Когда игра заработает, попробуй сам добавить что-нибудь из этого списка. Каждая идея небольшая:

  • Проверяй диапазон. Если догадка равна 0 или больше 100, скажи «Выбери число от 1 до 100» и не засчитывай попытку.
  • Ограничь число попыток. Дай игроку 7 попыток. Если они закончились, покажи секрет и заверши игру.
  • Сыграть ещё раз. Когда игрок выиграл, спроси «Ещё раз? (y/n)» и начни новый раунд с новым секретом.
  • Запоминай лучший результат. Храни наименьшее число попыток за все раунды и выведи его в конце.
  • Давай подсказки «тепло-холодно». Говори «очень близко!», если догадка отличается от секрета не больше чем на 3. guess.abs_diff(secret) возвращает расстояние между двумя числами.
  • Возьми настоящий крейт rand. Выполни cargo add rand и по документации на docs.rs (на английском) замени secret_number().
  • Вынеси логику в функции. Напиши fn check_guess(guess: u32, secret: u32) -> Ordering и добавь для неё тесты.
  • io::stdin().read_line(&mut s) читает строку вместе с переводом строки и возвращает Result. Ok(0) значит, что ввода больше нет.
  • .trim().parse() превращает текст в число. Он возвращает Result, поэтому делай по нему match, чтобы мягко обработать неверный ввод.
  • a.cmp(&b) возвращает Ordering::Less, Greater или Equal, а match следит, чтобы ты обработал все три случая.
  • loop вместе с continue и break продолжает игру, пока игрок не победит.
  • Трюк с часами годится для игры, но в настоящих проектах используют крейт rand.

Проверь себя

1. Игрок вводит 42 и нажимает Enter. Что окажется в строке после read_line?

2. Почему игра делает match по parse(), а не вызывает .expect()?

3. Что значит, если read_line вернул Ok(0)?

4. Какие значения может вернуть guess.cmp(&secret)?

Результат: 0 / 4