Проект: игра «Угадай число» на Rust
Это твой первый настоящий проект. Компьютер загадывает секретное число от 1 до 100. Ты угадываешь. Он отвечает «слишком мало» или «слишком много», пока ты не попадёшь, а потом говорит, сколько попыток тебе понадобилось.
Программа небольшая, но в ней собрано многое из того, что ты уже знаешь: переменные, match, циклы и Result. Будем строить её по шагу за раз, и каждый шаг можно запустить отдельно.
Что у нас получится
Заголовок раздела «Что у нас получится»Когда закончишь, игра на твоём компьютере будет выглядеть так:
Угадай число! (от 1 до 100)Введи свою догадку:50Слишком много!Введи свою догадку:приветЭто не целое число. Попробуй ещё раз!Введи свою догадку:25Слишком мало!Введи свою догадку:37Угадал! Попыток: 3Обрати внимание на две приятные детали. Когда игрок вводит привет, игра не падает, а просто спрашивает снова. И такой неверный ввод не считается попыткой.
Шаг 1: создаём проект
Заголовок раздела «Шаг 1: создаём проект»Открой терминал и создай новый проект Cargo:
cargo new guessing_gamecd guessing_gameВесь код с этой страницы пишется в src/main.rs. Запустить его можно в любой момент командой:
cargo runЕсли Rust у тебя ещё не установлен, сначала загляни в урок Начало работы.
Шаг 2: читаем догадку с клавиатуры
Заголовок раздела «Шаг 2: читаем догадку с клавиатуры»Чтобы прочитать то, что вводит игрок, используй 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". Запомни это — пригодится на следующем шаге.
Шаг 3: превращаем текст в число
Заголовок раздела «Шаг 3: превращаем текст в число»Игрок ввёл текст, а нам нужно число, которое можно сравнивать. .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 — вот как получается программа, которой приятно пользоваться.
Шаг 4: сравниваем догадку с секретом
Заголовок раздела «Шаг 4: сравниваем догадку с секретом»У каждого числа есть метод .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 есть плюс: компилятор проверяет, что ты обработал все три случая. Забудешь один — код не скомпилируется.
Шаг 5: спрашиваем, пока не угадают
Заголовок раздела «Шаг 5: спрашиваем, пока не угадают»Теперь обернём всё в 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).
Шаг 6: выбираем случайное секретное число
Заголовок раздела «Шаг 6: выбираем случайное секретное число»Игра, в которой каждый раз один и тот же секрет, быстро надоест. В настоящем проекте ты взял бы крейт rand (на английском) — это стандартный выбор для случайных чисел в Rust. Как им пользоваться, показано в уроке Случайные числа. Добавляется он так:
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? truenanos % 100 всегда от 0 до 99, так что после прибавления 1 получается от 1 до 100. Мы печатаем только то, попадает ли число в диапазон, поэтому вывод каждый раз одинаковый. Хочешь подсмотреть — выведи сам secret.
Будь честен с собой: это «достаточно непредсказуемо для игры», но не настоящая случайность. Никогда не используй такой трюк для паролей, токенов безопасности и всего, что кто-то может попытаться угадать. Для этого бери нормальный крейт.
Шаг 7: игра целиком
Заголовок раздела «Шаг 7: игра целиком»Вот всё вместе. Эту версию положи в 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
Что почитать
Заголовок раздела «Что почитать»- Программирование игры в загадки — книга «Язык программирования Rust» (перевод на русский): версия этого проекта из самой книги. Там используется крейт
rand, и каждая строка разобрана подробно. Stdin::read_line— документация std (на английском): почему текст дописывается в твоюString, почему перевод строки остаётся и почему в конце ввода возвращаетсяOk(0).str::parse— документация std (на английском): какparseвыбирает целевой тип по твоей аннотации и как выглядит ошибка, если текст не число.std::cmp::Ordering— документация std (на английском): перечислениеLess/Equal/Greater, которое возвращаетcmp, и полезные методы вродеis_ltиreverse.- Крейт
rand— docs.rs (на английском): правильный способ получать случайные числа, когда будешь готов добавить свой первый крейт.