Bỏ qua để đến nội dung

Dự án: trò chơi đoán số bằng Rust

Đây là dự án thật đầu tiên của bạn. Máy tính chọn một số bí mật từ 1 đến 100. Bạn đoán. Nó báo “nhỏ quá” hoặc “lớn quá” cho đến khi bạn đoán đúng, rồi cho bạn biết mình đã cần bao nhiêu lần thử.

Chương trình nhỏ thôi, nhưng nó dùng rất nhiều thứ bạn đã học: biến, match, vòng lặp và Result. Chúng ta sẽ xây nó từng bước một, và bước nào cũng tự chạy được.

Khi xong, trò chơi trên máy của bạn sẽ trông như thế này:

Đoán số đi! (từ 1 đến 100)
Hãy nhập số bạn đoán:
50
Lớn quá!
Hãy nhập số bạn đoán:
hello
Đó không phải là số nguyên. Thử lại nhé!
Hãy nhập số bạn đoán:
25
Nhỏ quá!
Hãy nhập số bạn đoán:
37
Bạn đoán đúng sau 3 lần!

Để ý hai chi tiết thân thiện. Khi người chơi gõ hello, trò chơi không crash. Nó hỏi lại. Và lần nhập sai đó không bị tính là một lần đoán.

Mở terminal và tạo một project Cargo mới:

Terminal window
cargo new guessing_game
cd guessing_game

Toàn bộ code trên trang này nằm trong src/main.rs. Chạy nó bất cứ lúc nào bằng:

Terminal window
cargo run

Nếu bạn chưa cài Rust, hãy xem Bắt đầu trước.

Để đọc những gì người chơi gõ, hãy dùng std::io::stdin() và phương thức read_line của nó:

use std::io;
fn main() {
println!("Hãy nhập số bạn đoán:");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
println!("Bạn đoán: {guess}");
}

Chương trình này chờ bạn gõ gì đó và nhấn Enter, nên hãy chạy nó bằng cargo run trên máy của bạn. (Rust Playground không có bàn phím để nhập, nên ở đây không có nút “Try it”.)

Có vài chuyện đang diễn ra:

  • String::new() tạo một String rỗng, có thể lớn dần, để chứa chữ.
  • read_line(&mut guess) thêm dòng được gõ vào cuối guess. Nó cần &mut vì nó thay đổi chuỗi. Xem Mượn nếu &mut còn mới với bạn.
  • read_line trả về một Result, vì việc đọc dữ liệu nhập có thể thất bại. .expect(...) nói rằng “nếu thất bại, hãy dừng chương trình với thông báo này”. Tạm thời như vậy là ổn; lát nữa ta sẽ xử lý nhẹ nhàng hơn.

Một điều bất ngờ: chữ nhận được có cả phím Enter. Nếu bạn gõ 42, guess chứa "42\n". Hãy nhớ điều đó cho bước tiếp theo.

Người chơi gõ chữ, nhưng ta muốn một con số để so sánh. .trim() bỏ khoảng trắng và ký tự xuống dòng ở hai đầu. Sau đó .parse() thử biến chữ thành số.

Việc parse có thể thất bại. "seven" không phải là số. Vì vậy parse() trả về một Result, và ta dùng match để xử lý cả hai trường hợp:

fn main() {
// Giả vờ người chơi đã gõ những dòng này (mỗi dòng kết thúc bằng Enter).
let inputs = ["42\n", " 7 \n", "seven\n", "-3\n"];
for input in inputs {
match input.trim().parse::<u32>() {
Ok(num) => println!("{:?} -> số {num}", input),
Err(e) => println!("{:?} -> không phải số đoán hợp lệ ({e})", input),
}
}
}

Kết quả:

"42\n" -> số 42
" 7 \n" -> số 7
"seven\n" -> không phải số đoán hợp lệ (invalid digit found in string)
"-3\n" -> không phải số đoán hợp lệ (invalid digit found in string)
▶ Tự chạy thử

Thông báo lỗi trong ngoặc là của chính Rust, nên vẫn là tiếng Anh: “invalid digit found in string” nghĩa là “tìm thấy ký tự không phải chữ số trong chuỗi”. parse::<u32>() cho Rust biết cần parse thành kiểu nào. u32 là số nguyên không dấu (không bao giờ âm), vì vậy -3 bị từ chối. Đó đúng là điều ta muốn: số bí mật luôn nằm trong khoảng 1 đến 100.

Trong trò chơi, ta viết cùng ý tưởng đó như sau:

let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Đó không phải là số nguyên. Thử lại nhé!");
continue;
}
};

Ở đây kiểu đến từ let guess: u32, nên ta không cần ::<u32>. Nếu parse thành công, match trả về con số. Nếu thất bại, ta in một lời nhắn tử tế và continue để hỏi lại. (continue chỉ dùng được bên trong vòng lặp, thứ ta sẽ thêm ở Bước 5.)

Sao không dùng luôn .expect()? Vì khi đó một lỗi gõ phím sẽ làm sập cả trò chơi. Xử lý trường hợp Err là cách bạn làm ra một chương trình dễ chịu khi dùng.

Bước 4: So sánh số đoán với số bí mật

Phần tiêu đề “Bước 4: So sánh số đoán với số bí mật”

Mọi con số đều có phương thức .cmp(). Nó so sánh hai giá trị và trả về một std::cmp::Ordering, một enum với ba biến thể: Less (nhỏ hơn), Greater (lớn hơn) và Equal (bằng). Một match trên nó đọc gần như văn xuôi:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
for guess in [10, 42, 99] {
match guess.cmp(&secret) {
Ordering::Less => println!("{guess}: Nhỏ quá!"),
Ordering::Greater => println!("{guess}: Lớn quá!"),
Ordering::Equal => println!("{guess}: Bạn thắng!"),
}
}
}

Kết quả:

10: Nhỏ quá!
42: Bạn thắng!
99: Lớn quá!
▶ Tự chạy thử

Bạn có thể viết điều tương tự bằng if guess < secret ... else if .... Phiên bản match hay ở chỗ trình biên dịch kiểm tra rằng bạn đã xử lý cả ba trường hợp. Quên một cái là không biên dịch được.

Bước 5: Hỏi mãi cho đến khi đoán đúng

Phần tiêu đề “Bước 5: Hỏi mãi cho đến khi đoán đúng”

Giờ ta bọc mọi thứ trong một loop. Nó chạy mãi mãi cho đến khi ta break ra ngoài. Ta cũng giữ một bộ đếm, attempts, và chỉ cộng 1 khi số đoán là một con số thật.

Trong trò chơi thật, dữ liệu nhập đến từ bàn phím. Để có một phiên bản chạy được trên Playground, ta dùng một số bí mật cố định và một danh sách cố định các dòng “đã gõ”. inputs.next() cho ta dòng tiếp theo, hoặc None khi danh sách hết:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 37;
// Giả vờ người chơi đã gõ những dòng này, mỗi dòng một số.
let mut inputs = ["50", "hello", "25", "37"].into_iter();
let mut attempts = 0;
println!("Đoán số đi! (từ 1 đến 100)");
loop {
let input = match inputs.next() {
Some(line) => line,
None => {
println!("Hết lượt đoán. Số bí mật là {secret}.");
break;
}
};
println!("> {input}");
let guess: u32 = match input.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Đó không phải là số nguyên. Thử lại nhé!");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Nhỏ quá!"),
Ordering::Greater => println!("Lớn quá!"),
Ordering::Equal => {
println!("Bạn đoán đúng sau {attempts} lần!");
break;
}
}
}
}

Kết quả:

Đoán số đi! (từ 1 đến 100)
> 50
Lớn quá!
> hello
Đó không phải là số nguyên. Thử lại nhé!
> 25
Nhỏ quá!
> 37
Bạn đoán đúng sau 3 lần!
▶ Tự chạy thử

Hãy thử đổi số bí mật hoặc danh sách số đoán rồi chạy lại. Đặt đáp án đúng lên đầu, hoặc bỏ nó đi, và xem chuyện gì xảy ra.

Trường hợp None rất quan trọng. Đó là phiên bản Playground của tình huống “người chơi không còn gì để nhập”, và trò chơi thật cũng cần ý tưởng này (xem Bước 7).

Bước 6: Chọn một số bí mật ngẫu nhiên

Phần tiêu đề “Bước 6: Chọn một số bí mật ngẫu nhiên”

Một trò chơi lần nào cũng cùng một số bí mật thì chẳng vui mấy. Trong một project thật, bạn sẽ dùng crate rand (tiếng Anh), lựa chọn chuẩn cho số ngẫu nhiên trong Rust. Bài Số ngẫu nhiên chỉ cách dùng nó. Bạn thêm nó bằng:

Terminal window
cargo add rand

Trong dự án này, ta chỉ dùng thư viện chuẩn, để bạn không phải cài đặt gì. Mẹo là: hỏi đồng hồ thời gian hiện tại, lấy phần nano giây (một số từ 0 đến 999.999.999 thay đổi liên tục), rồi ép nó vào khoảng 1 đến 100 bằng % (phép chia lấy dư):

use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("đồng hồ đang đặt trước năm 1970")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
let secret = secret_number();
let in_range = (1..=100).contains(&secret);
println!("Đã chọn số bí mật. Nó có nằm trong khoảng 1 đến 100 không? {in_range}");
}

Kết quả:

Đã chọn số bí mật. Nó có nằm trong khoảng 1 đến 100 không? true
▶ Tự chạy thử

nanos % 100 luôn từ 0 đến 99, nên cộng 1 cho ta từ 1 đến 100. Ta chỉ in ra việc nó có nằm trong khoảng hay không, nên kết quả lần nào cũng giống nhau. Hãy in chính secret nếu bạn muốn nhìn trộm.

Hãy thành thật với bản thân về bản chất của nó: đây là “đủ khó đoán cho một trò chơi”, không phải ngẫu nhiên thật. Đừng bao giờ dùng mẹo như thế này cho mật khẩu, token bảo mật, hay bất cứ thứ gì mà ai đó có thể cố đoán giá trị. Với những thứ đó, hãy dùng một crate đàng hoàng.

Đây là mọi thứ ghép lại. Đây là phiên bản để đặt vào src/main.rs và chơi bằng cargo run.

Một điều mới: ta dùng match trên kết quả của read_line thay vì gọi .expect(). read_line trả về Ok(0) khi không còn dữ liệu nhập, ví dụ khi người chơi nhấn Ctrl+D (Ctrl+Z rồi Enter trên Windows), hoặc khi dữ liệu được đưa vào từ một file qua pipe. Thiếu phép kiểm tra đó, vòng lặp sẽ quay mãi trên dữ liệu rỗng.

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("đồng hồ đang đặt trước năm 1970")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
println!("Đoán số đi! (từ 1 đến 100)");
let secret = secret_number();
let mut attempts = 0;
loop {
println!("Hãy nhập số bạn đoán:");
let mut guess = String::new();
match io::stdin().read_line(&mut guess) {
Ok(0) => {
// Hết dữ liệu nhập: không còn gì để đọc.
println!("Hết dữ liệu nhập. Số bí mật là {secret}. Tạm biệt!");
break;
}
Ok(_) => {}
Err(e) => {
println!("Không đọc được dữ liệu bạn nhập: {e}");
break;
}
}
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Đó không phải là số nguyên. Thử lại nhé!");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Nhỏ quá!"),
Ordering::Greater => println!("Lớn quá!"),
Ordering::Equal => {
println!("Bạn đoán đúng sau {attempts} lần!");
break;
}
}
}
}

Muốn thử trong trình duyệt thay vì trên máy, hãy dùng phiên bản ở Bước 5, phiên bản chơi theo một danh sách số đoán cố định. 🦀

Quên mut cho chuỗi. read_line cần thay đổi chuỗi, nên chuỗi phải khả biến (mutable):

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();
| +++

Trình biên dịch báo rằng không thể mượn guess dưới dạng khả biến vì nó không được khai báo là khả biến. Cách sửa đúng như trình biên dịch gợi ý: let mut guess = String::new();.

So sánh chữ với số. Nếu bạn quên parse, guess vẫn là một String, và bạn không thể so sánh một String với một u32:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
let guess = String::from("50");
match guess.cmp(&secret) {
Ordering::Less => println!("Nhỏ quá!"),
Ordering::Greater => println!("Lớn quá!"),
Ordering::Equal => println!("Bạn thắng!"),
}
}
error[E0308]: mismatched types
--> src/main.rs:7:21
|
7 | match guess.cmp(&secret) {
| --- ^^^^^^^ expected `&String`, found `&u32`
| |
| arguments to this method are incorrect

Lỗi này nghĩa là kiểu không khớp: phương thức chờ một &String nhưng lại nhận một &u32. Rust không bao giờ tự chuyển đổi giữa chữ và số giúp bạn. Hãy parse trước, rồi mới so sánh.

Quên .trim(). Đoạn này biên dịch được, nhưng số đoán nào cũng bị từ chối, vì ký tự xuống dòng không phải là chữ số:

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

Kết quả:

Err(ParseIntError { kind: InvalidDigit })
Ok(42)
▶ Tự chạy thử

Khi trò chơi đã chạy, hãy tự thử những điều này. Mỗi cái đều nhỏ:

  • Kiểm tra khoảng giá trị. Nếu số đoán là 0 hoặc lớn hơn 100, hãy nói “Hãy chọn một số từ 1 đến 100” và không tính lần đó.
  • Giới hạn số lần đoán. Cho người chơi 7 lần. Nếu hết lượt, hãy hiện số bí mật và kết thúc trò chơi.
  • Chơi lại. Khi người chơi thắng, hỏi “Chơi lại không? (c/k)” và bắt đầu một ván mới với số bí mật mới.
  • Lưu điểm tốt nhất. Ghi nhớ số lần đoán ít nhất qua các ván và in ra ở cuối.
  • Gợi ý “nóng lạnh”. Nói “gần lắm rồi!” khi số đoán cách số bí mật không quá 3. guess.abs_diff(secret) cho bạn khoảng cách giữa hai số.
  • Dùng crate rand thật. Chạy cargo add rand và làm theo tài liệu của nó trên docs.rs (tiếng Anh) để thay thế secret_number().
  • Tách logic ra thành hàm. Viết fn check_guess(guess: u32, secret: u32) -> Ordering và thêm bài test cho nó.
  • io::stdin().read_line(&mut s) đọc một dòng, bao gồm cả ký tự xuống dòng, và trả về một Result. Ok(0) nghĩa là không còn dữ liệu nhập.
  • .trim().parse() biến chữ thành số. Nó trả về một Result, nên hãy match trên nó để xử lý dữ liệu nhập sai một cách tử tế.
  • a.cmp(&b) trả về Ordering::Less, Greater hoặc Equal, và match bảo đảm bạn xử lý cả ba.
  • loop cùng continue và break giữ cho trò chơi tiếp diễn đến khi người chơi thắng.
  • Mẹo dùng đồng hồ ổn cho một trò chơi, nhưng project thật dùng crate rand.

Kiểm tra nhanh

1. Người chơi gõ 42 rồi nhấn Enter. Chuỗi nhận từ read_line chứa gì?

2. Vì sao trò chơi dùng match trên parse() thay vì .expect()?

3. read_line trả về Ok(0) nghĩa là gì?

4. guess.cmp(&secret) có thể trả về những giá trị nào?

Điểm: 0 / 4

  • Programming a Guessing Game — The Rust Book (tiếng Anh): phiên bản dự án này của chính cuốn Rust Book. Nó dùng crate rand và giải thích chi tiết từng dòng.
  • Stdin::read_line — std docs (tiếng Anh): vì sao chữ được nối thêm vào String của bạn, vì sao nó giữ ký tự xuống dòng, và vì sao nó trả về Ok(0) khi hết dữ liệu nhập.
  • str::parse — std docs (tiếng Anh): parse chọn kiểu đích từ chú thích kiểu của bạn như thế nào, và lỗi trông ra sao khi chữ không phải là số.
  • std::cmp::Ordering — std docs (tiếng Anh): enum Less / Equal / Greater mà cmp trả về, cùng các hàm tiện ích như is_lt và reverse.
  • Crate rand — docs.rs (tiếng Anh): cách thật sự để chọn số ngẫu nhiên, khi bạn sẵn sàng thêm crate đầu tiên.