Lewati ke konten

Proyek: Game Tebak Angka dengan Rust

Ini proyek sungguhan pertamamu. Komputer memilih sebuah angka rahasia antara 1 dan 100. Kamu menebak. Komputer bilang “terlalu kecil” atau “terlalu besar” sampai tebakanmu benar, lalu memberi tahu berapa kali percobaan yang kamu butuhkan.

Programnya kecil, tapi memakai banyak hal yang sudah kamu pelajari: variabel, match, perulangan (loop), dan Result. Kita akan membangunnya selangkah demi selangkah, dan setiap langkah bisa dijalankan sendiri.

Kalau sudah selesai, game di komputermu akan terlihat seperti ini:

Tebak angkanya! (1 sampai 100)
Ketik tebakanmu:
50
Terlalu besar!
Ketik tebakanmu:
halo
Itu bukan bilangan bulat. Coba lagi!
Ketik tebakanmu:
25
Terlalu kecil!
Ketik tebakanmu:
37
Kamu berhasil dalam 3 percobaan!

Perhatikan dua detail yang ramah. Saat pemain mengetik halo, game-nya tidak crash. Ia bertanya lagi. Dan input yang salah itu tidak dihitung sebagai percobaan.

Buka terminal dan buat proyek Cargo baru:

Terminal window
cargo new guessing_game
cd guessing_game

Semua kode di halaman ini masuk ke src/main.rs. Jalankan kapan saja dengan:

Terminal window
cargo run

Kalau kamu belum menginstal Rust, baca Mulai dengan Rust dulu.

Untuk membaca apa yang diketik pemain, pakai std::io::stdin() dan method read_line-nya:

use std::io;
fn main() {
println!("Ketik tebakanmu:");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Gagal membaca baris");
println!("Tebakanmu: {guess}");
}

Program ini menunggu kamu mengetik sesuatu dan menekan Enter, jadi jalankan dengan cargo run di komputermu sendiri. (Rust Playground tidak punya input keyboard, karena itu tidak ada tombol “Coba sendiri” di sini.)

Ada beberapa hal yang terjadi:

  • String::new() membuat sebuah String kosong yang bisa bertambah panjang untuk menampung teksnya.
  • read_line(&mut guess) menambahkan baris yang diketik ke ujung guess. Ia butuh &mut karena mengubah string-nya. Baca Borrowing kalau &mut masih baru buatmu.
  • read_line mengembalikan Result, karena membaca input bisa gagal. .expect(...) artinya “kalau ini gagal, hentikan program dengan pesan ini”. Untuk sekarang itu tidak masalah; nanti kita akan menanganinya dengan lebih halus.

Satu hal yang mengejutkan: teksnya ikut menyimpan tombol Enter. Kalau kamu mengetik 42, isi guess adalah "42\n". Ingat ini untuk langkah berikutnya.

Pemain mengetik teks, padahal kita butuh angka yang bisa dibandingkan. .trim() menghapus spasi dan karakter baris baru di kedua ujung. Lalu .parse() mencoba mengubah teksnya menjadi angka.

Parsing bisa gagal. "tujuh" bukan angka. Jadi parse() mengembalikan Result, dan kita memakai match untuk menangani kedua kemungkinan:

fn main() {
// Anggap saja pemain mengetik baris-baris ini (masing-masing diakhiri Enter).
let inputs = ["42\n", " 7 \n", "tujuh\n", "-3\n"];
for input in inputs {
match input.trim().parse::<u32>() {
Ok(num) => println!("{:?} -> angka {num}", input),
Err(e) => println!("{:?} -> bukan tebakan yang valid ({e})", input),
}
}
}

Output:

"42\n" -> angka 42
" 7 \n" -> angka 7
"tujuh\n" -> bukan tebakan yang valid (invalid digit found in string)
"-3\n" -> bukan tebakan yang valid (invalid digit found in string)
▶ Coba sendiri

Pesan di dalam kurung berasal dari Rust sendiri, jadi tetap dalam bahasa Inggris: “invalid digit found in string” artinya “ada digit yang tidak valid di dalam string”. parse::<u32>() memberi tahu Rust tipe tujuan parsing-nya. u32 adalah bilangan bulat tanpa tanda (tidak pernah negatif), karena itu -3 ditolak. Memang itu yang kita mau: angka rahasianya selalu di antara 1 dan 100.

Di dalam game, kita menulis ide yang sama seperti ini:

let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Itu bukan bilangan bulat. Coba lagi!");
continue;
}
};

Di sini tipenya berasal dari let guess: u32, jadi kita tidak perlu ::<u32>. Kalau parsing berhasil, match mengembalikan angkanya. Kalau gagal, kita mencetak pesan yang ramah dan memakai continue untuk bertanya lagi. (continue hanya bisa dipakai di dalam loop, yang akan kita tambahkan di Langkah 5.)

Kenapa tidak pakai .expect() saja? Karena dengan begitu, satu salah ketik saja akan membuat seluruh game crash. Menangani kasus Err adalah cara membuat program yang nyaman dipakai.

Langkah 4: Membandingkan tebakan dengan angka rahasia

Section titled “Langkah 4: Membandingkan tebakan dengan angka rahasia”

Setiap angka punya method .cmp(). Method ini membandingkan dua nilai dan mengembalikan std::cmp::Ordering, sebuah enum dengan tiga varian: Less (lebih kecil), Greater (lebih besar), dan Equal (sama). match padanya hampir terbaca seperti kalimat biasa:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
for guess in [10, 42, 99] {
match guess.cmp(&secret) {
Ordering::Less => println!("{guess}: Terlalu kecil!"),
Ordering::Greater => println!("{guess}: Terlalu besar!"),
Ordering::Equal => println!("{guess}: Kamu menang!"),
}
}
}

Output:

10: Terlalu kecil!
42: Kamu menang!
99: Terlalu besar!
▶ Coba sendiri

Kamu bisa menulis hal yang sama dengan if guess < secret ... else if .... Versi match lebih enak karena compiler memeriksa bahwa kamu sudah menangani ketiga kasus. Lupa satu saja, kodenya tidak akan ter-compile.

Langkah 5: Terus bertanya sampai tebakannya benar

Section titled “Langkah 5: Terus bertanya sampai tebakannya benar”

Sekarang kita bungkus semuanya dengan loop. Loop ini berjalan terus sampai kita keluar dengan break. Kita juga menyimpan penghitung, attempts, dan hanya menambahkan 1 kalau tebakannya benar-benar angka.

Di game sungguhan, input datang dari keyboard. Supaya ada versi yang bisa jalan di Playground, kita pakai angka rahasia tetap dan daftar baris “ketikan” yang tetap. inputs.next() memberi baris berikutnya, atau None kalau daftarnya sudah habis:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 37;
// Anggap saja pemain mengetik ini, satu per baris.
let mut inputs = ["50", "halo", "25", "37"].into_iter();
let mut attempts = 0;
println!("Tebak angkanya! (1 sampai 100)");
loop {
let input = match inputs.next() {
Some(line) => line,
None => {
println!("Tebakan habis. Angkanya adalah {secret}.");
break;
}
};
println!("> {input}");
let guess: u32 = match input.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Itu bukan bilangan bulat. Coba lagi!");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Terlalu kecil!"),
Ordering::Greater => println!("Terlalu besar!"),
Ordering::Equal => {
println!("Kamu berhasil dalam {attempts} percobaan!");
break;
}
}
}
}

Output:

Tebak angkanya! (1 sampai 100)
> 50
Terlalu besar!
> halo
Itu bukan bilangan bulat. Coba lagi!
> 25
Terlalu kecil!
> 37
Kamu berhasil dalam 3 percobaan!
▶ Coba sendiri

Coba ganti angka rahasia atau daftar tebakannya, lalu jalankan lagi. Taruh jawaban yang benar di urutan pertama, atau hapus sama sekali, dan lihat apa yang terjadi.

Kasus None itu penting. Itulah versi Playground dari “pemain sudah tidak punya input lagi”, dan game sungguhan juga butuh ide yang sama (lihat Langkah 7).

Langkah 6: Memilih angka rahasia secara acak

Section titled “Langkah 6: Memilih angka rahasia secara acak”

Game yang angka rahasianya selalu sama tidak terlalu seru. Di proyek sungguhan, kamu akan memakai crate rand (bahasa Inggris), pilihan standar untuk angka acak di Rust. Pelajaran Angka acak menunjukkan cara memakainya. Kamu menambahkannya dengan:

Terminal window
cargo add rand

Untuk proyek ini kita tetap memakai standard library saja, jadi kamu tidak perlu setup apa pun. Triknya: tanya jam komputer untuk waktu saat ini, ambil bagian nanodetiknya (angka dari 0 sampai 999.999.999 yang terus berubah), lalu “peras” ke rentang 1 sampai 100 dengan % (sisa bagi):

use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("jam diatur ke sebelum 1970")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
let secret = secret_number();
let in_range = (1..=100).contains(&secret);
println!("Angka rahasia sudah dipilih. Apakah di antara 1 dan 100? {in_range}");
}

Output:

Angka rahasia sudah dipilih. Apakah di antara 1 dan 100? true
▶ Coba sendiri

nanos % 100 selalu bernilai 0 sampai 99, jadi setelah ditambah 1 hasilnya 1 sampai 100. Kita hanya mencetak apakah angkanya ada di dalam rentang, jadi output-nya selalu sama. Cetak secret langsung kalau kamu ingin mengintip.

Jujurlah pada dirimu sendiri soal apa ini sebenarnya: ini “cukup sulit ditebak untuk sebuah game”, bukan keacakan yang sesungguhnya. Jangan pernah memakai trik seperti ini untuk password, token keamanan, atau apa pun yang nilainya mungkin coba ditebak orang. Untuk hal-hal seperti itu, pakai crate yang tepat.

Ini semuanya digabung. Versi inilah yang kamu taruh di src/main.rs dan mainkan dengan cargo run.

Ada satu hal baru: kita memakai match pada hasil read_line, bukan .expect(). read_line mengembalikan Ok(0) saat tidak ada input lagi, misalnya kalau pemain menekan Ctrl+D (di Windows: Ctrl+Z lalu Enter), atau kalau input dikirim lewat pipe dari sebuah file. Tanpa pengecekan itu, loop-nya akan berputar selamanya pada input kosong.

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("jam diatur ke sebelum 1970")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
println!("Tebak angkanya! (1 sampai 100)");
let secret = secret_number();
let mut attempts = 0;
loop {
println!("Ketik tebakanmu:");
let mut guess = String::new();
match io::stdin().read_line(&mut guess) {
Ok(0) => {
// Input sudah habis: tidak ada lagi yang bisa dibaca.
println!("Input habis. Angkanya adalah {secret}. Sampai jumpa!");
break;
}
Ok(_) => {}
Err(e) => {
println!("Gagal membaca input-mu: {e}");
break;
}
}
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Itu bukan bilangan bulat. Coba lagi!");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Terlalu kecil!"),
Ordering::Greater => println!("Terlalu besar!"),
Ordering::Equal => {
println!("Kamu berhasil dalam {attempts} percobaan!");
break;
}
}
}
}

Kalau ingin mencobanya di browser, pakai versi Langkah 5, yang memainkan daftar tebakan tetap. 🦀

Lupa menulis mut pada string-nya. read_line perlu mengubah string-nya, jadi string itu harus mutable (dapat diubah):

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

Compiler bilang guess tidak bisa dipinjam sebagai mutable karena tidak dideklarasikan sebagai mutable. Solusinya persis seperti saran compiler: let mut guess = String::new();.

Membandingkan teks dengan angka. Kalau kamu lupa mem-parse, guess masih berupa String, dan kamu tidak bisa membandingkan String dengan u32:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
let guess = String::from("50");
match guess.cmp(&secret) {
Ordering::Less => println!("Terlalu kecil!"),
Ordering::Greater => println!("Terlalu besar!"),
Ordering::Equal => println!("Kamu menang!"),
}
}
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” artinya tipenya tidak cocok: method itu mengharapkan &String, tapi menerima &u32. Rust tidak pernah mengonversi antara teks dan angka secara otomatis. Parse dulu, baru bandingkan.

Lupa memanggil .trim(). Kode ini ter-compile, tapi setiap tebakan ditolak, karena karakter baris baru bukan digit:

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

Output:

Err(ParseIntError { kind: InvalidDigit })
Ok(42)
▶ Coba sendiri

Setelah game-mu berjalan, coba ide-ide ini sendiri. Masing-masing kecil saja:

  • Periksa rentangnya. Kalau tebakannya 0 atau lebih dari 100, tulis “Pilih angka dari 1 sampai 100” dan jangan dihitung.
  • Batasi jumlah percobaan. Beri pemain 7 kesempatan. Kalau habis, tunjukkan angka rahasianya dan akhiri game.
  • Main lagi. Saat pemain menang, tanyakan “Main lagi? (y/n)” dan mulai ronde baru dengan angka rahasia baru.
  • Simpan skor terbaik. Ingat jumlah percobaan paling sedikit dari semua ronde dan cetak di akhir.
  • Beri petunjuk “hangat”. Tulis “sudah dekat sekali!” saat tebakan berjarak 3 atau kurang dari angka rahasia. guess.abs_diff(secret) memberi jarak antara dua angka.
  • Pakai crate rand yang sebenarnya. Jalankan cargo add rand dan ikuti dokumentasinya di docs.rs (bahasa Inggris) untuk mengganti secret_number().
  • Pindahkan logika ke fungsi. Tulis fn check_guess(guess: u32, secret: u32) -> Ordering dan tambahkan test untuknya.
  • io::stdin().read_line(&mut s) membaca satu baris, termasuk karakter baris baru, dan mengembalikan Result. Ok(0) berarti input sudah habis.
  • .trim().parse() mengubah teks menjadi angka. Hasilnya Result, jadi pakai match untuk menangani input yang salah dengan ramah.
  • a.cmp(&b) mengembalikan Ordering::Less, Greater, atau Equal, dan match memastikan kamu menangani ketiganya.
  • loop dengan continue dan break membuat game terus berjalan sampai pemain menang.
  • Trik jam komputer cukup untuk game, tapi proyek sungguhan memakai crate rand.

Kuis singkat

1. Pemain mengetik 42 lalu menekan Enter. Apa isi string dari read_line?

2. Kenapa game ini memakai match pada parse(), bukan .expect()?

3. Apa artinya kalau read_line mengembalikan Ok(0)?

4. Nilai apa saja yang bisa dikembalikan guess.cmp(&secret)?

Skor: 0 / 4