Trong bài này chúng ta sẽ làm game rắn săn mồi C++ có đồ họa hẳn hoi: một cửa sổ riêng, rắn chạy bằng phím mũi tên, ăn mồi thì dài ra và cộng điểm, đâm tường hoặc cắn vào thân thì thua. Game dùng thư viện SFML, và toàn bộ chương trình chỉ khoảng 160 dòng. Qua project này các bạn sẽ hiểu vòng lặp game, thứ nằm bên dưới mọi game từ rắn săn mồi tới game 3D.
Bài 38 trong Serie Lập trình C++ từ A tới Z
SFML là gì?
SFML (Simple and Fast Multimedia Library) là thư viện C++ để mở cửa sổ, vẽ đồ họa 2D, phát âm thanh, nhận bàn phím và chuột. Thư viện chuẩn của C++ không có gì để vẽ lên màn hình, nên muốn làm game ta cần một thư viện như SFML hoặc SDL. SFML được viết theo phong cách hướng đối tượng: cửa sổ là sf::RenderWindow, hình chữ nhật là sf::RectangleShape, chữ là sf::Text, rất dễ đọc với những gì các bạn đã học trong serie.
Bài này dùng SFML 3, bản mới viết lại theo C++17. Nhiều hướng dẫn trên mạng vẫn dùng SFML 2 với cú pháp khác (sf::Event e; while (window.pollEvent(e))), code của SFML 2 không biên dịch được với SFML 3 và ngược lại, các bạn để ý khi tham khảo.
Cài đặt SFML
Trên Windows với MSYS2
Trang tải của SFML có sẵn bản zip cho Windows, nhưng chính trang đó cảnh báo: phiên bản trình biên dịch phải khớp 100% với bản đã dùng để build SFML. Lệch một chút là lỗi liên kết rất khó hiểu.
Vì vậy cách dễ nhất là cài qua MSYS2, đúng bộ g++ mà chúng ta đã cài ở bài hướng dẫn cài đặt C++ trên VS Code. MSYS2 tự cài SFML khớp với g++ của nó. Mở terminal MSYS2 UCRT64 và chạy:
pacman -S mingw-w64-ucrt-x86_64-sfml
Thư viện và các file .dll được cài vào C:\msys64\ucrt64, thư mục bin của nó đã nằm trong Path từ bài cài đặt nên chương trình chạy được ngay.
Trên Ubuntu
Ubuntu 26.04 có sẵn SFML 3 trong kho:
sudo apt install libsfml-dev
Lưu ý: Ubuntu 24.04 trở về trước chỉ có SFML 2.x trong kho, code trong bài sẽ không biên dịch được. Với các bản này, các bạn tải mã nguồn SFML 3 trên GitHub và build bằng CMake theo hướng dẫn trên trang chủ SFML.
Font chữ
Game cần một file font để in điểm. Mình đặt file font.ttf cạnh main.cpp, lấy từ font có sẵn của hệ điều hành.
Trên Windows, chạy trong cmd:
copy C:\Windows\Fonts\arial.ttf font.ttf
Trên Ubuntu:
cp /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf font.ttf
Vòng lặp game
Chương trình console ở các bài trước chạy từ trên xuống dưới, chờ người dùng nhập rồi xử lý. Game thì khác: dù người chơi không bấm gì, rắn vẫn phải chạy và màn hình vẫn phải vẽ lại. Mọi game đều xoay quanh một vòng lặp lặp liên tục 3 bước:
- Xử lý sự kiện: lấy hết các phím vừa bấm, nút đóng cửa sổ.
- Cập nhật: tính trạng thái mới của game. Rắn chỉ đi 1 ô mỗi nhịp 120 ms, nên bước này chỉ chạy khi đã đủ 1 nhịp.
- Vẽ: xóa màn hình, vẽ lại toàn bộ, rồi đưa lên màn hình một lần.
Vòng lặp chạy khoảng 60 lần mỗi giây, nhưng rắn chỉ di chuyển khoảng 8 lần mỗi giây. Tách “tốc độ vẽ” khỏi “tốc độ game” như vậy giúp phím bấm được nhận ngay, còn tốc độ rắn thì không phụ thuộc máy nhanh hay chậm.
Biểu diễn con rắn bằng std::deque
Bàn chơi là lưới 20 cột x 15 hàng, mỗi ô 30 pixel. Mỗi đốt của rắn là một ô, lưu bằng struct ViTri gồm cột x và hàng y. Cả con rắn là một std::deque<ViTri>, phần tử đầu tiên là đầu rắn.
Vì sao là deque mà không phải vector? Mỗi nhịp, rắn di chuyển bằng cách thêm 1 ô mới ở đầu và bỏ 1 ô ở đuôi. deque thêm, bỏ ở cả hai đầu đều rất nhanh, còn vector thêm ở đầu thì phải dời toàn bộ phần tử phía sau.
Mẹo đẹp nhất của game nằm ở đây: khi ăn mồi, ta chỉ thêm đầu mà không bỏ đuôi, thế là rắn tự dài thêm 1 ô, không cần code riêng cho việc “mọc dài”.
struct ViTri
{
int x, y;
bool operator==(const ViTri& b) const { return x == b.x && y == b.y; }
};
Toán tử == được nạp chồng như bài nạp chồng toán tử để so sánh 2 vị trí gọn gàng: dau == moi_.
Class TroChoi: logic của game
Toàn bộ luật chơi nằm trong class TroChoi, tách khỏi phần cửa sổ và phím bấm. Hàm main chỉ việc gọi doiHuong, capNhat, ve.
Đổi hướng và chống quay đầu
void doiHuong(ViTri h)
{
if (h.x != -huong_.x || h.y != -huong_.y) // khong cho quay dau 180 do
huongMoi_ = h;
}
Hướng đi cũng là một ViTri: sang phải là {1, 0}, đi lên là {0, -1} (trục y của màn hình hướng xuống). Rắn đang đi sang phải mà bấm sang trái thì đầu sẽ đâm ngay vào thân, nên hướng ngược hẳn hướng hiện tại bị bỏ qua.
Có 2 biến hướng: huong_ là hướng đang đi, huongMoi_ là hướng người chơi vừa bấm. Nếu chỉ dùng một biến, người chơi bấm thật nhanh Lên rồi Trái trong cùng một nhịp thì hướng đã đổi 2 lần mà rắn chưa đi ô nào, lách được qua phép kiểm tra và rắn quay đầu cắn thân. Với 2 biến, mọi phép kiểm tra đều so với hướng thật sự đang đi.
Cập nhật mỗi nhịp: di chuyển, va chạm, ăn mồi
void capNhat() // goi moi "nhip" cua game
{
if (thua_)
return;
huong_ = huongMoi_;
ViTri dau = {ran_.front().x + huong_.x, ran_.front().y + huong_.y};
bool dungTuong = dau.x < 0 || dau.x >= SO_COT || dau.y < 0 || dau.y >= SO_HANG;
bool canThan = false;
for (const ViTri& p : ran_)
if (p == dau)
canThan = true;
if (dungTuong || canThan)
{
thua_ = true;
return;
}
ran_.push_front(dau);
if (dau == moi_)
{
diem_ += 10;
datMoi(); // an moi: giu nguyen duoi, ran dai them
}
else
{
ran_.pop_back(); // khong an: bo duoi, ran di chuyen
}
}
Thứ tự trong hàm này là logic chính của game:
- Tính vị trí đầu mới = đầu cũ + hướng.
- Kiểm tra va chạm trước khi di chuyển: ra ngoài lưới là đâm tường, trùng một đốt thân là tự cắn. Có va chạm thì bật cờ
thua_và dừng. - Thêm đầu mới bằng
push_front. Nếu đầu trùng mồi thì cộng điểm và đặt mồi mới, ngược lạipop_backbỏ đuôi.
Đặt mồi ngẫu nhiên
void datMoi()
{
std::uniform_int_distribution<int> cot(0, SO_COT - 1), hang(0, SO_HANG - 1);
while (true)
{
moi_ = {cot(sinhSo_), hang(sinhSo_)};
bool trungRan = false;
for (const ViTri& p : ran_)
if (p == moi_)
trungRan = true;
if (!trungRan)
return;
}
}
Số ngẫu nhiên dùng thư viện <random> của C++11: std::mt19937 là bộ sinh số, khởi tạo một lần trong constructor bằng std::random_device, còn uniform_int_distribution cho số đều trong một khoảng. Cách này cho kết quả tốt hơn rand() % 20 kiểu C. Mồi rơi trúng thân rắn thì chọn lại.
Vẽ bằng SFML
void ve(sf::RenderWindow& cuaSo, const sf::Font& font) const
{
sf::RectangleShape o({O - 2.f, O - 2.f});
o.setFillColor(sf::Color(220, 60, 60));
o.setPosition({moi_.x * O + 1.f, moi_.y * O + 1.f});
cuaSo.draw(o);
for (std::size_t i = 0; i < ran_.size(); i++)
{
o.setFillColor(i == 0 ? sf::Color(40, 160, 70) : sf::Color(90, 200, 110));
o.setPosition({ran_[i].x * O + 1.f, ran_[i].y * O + 1.f});
cuaSo.draw(o);
}
sf::Text chu(font, "Diem: " + std::to_string(diem_), 18);
chu.setPosition({8.f, 4.f});
cuaSo.draw(chu);
...
}
Một sf::RectangleShape được dùng lại để vẽ mọi ô: đổi màu, đổi vị trí rồi draw. Ô vuông nhỏ hơn ô lưới 2 pixel và lệch vào 1 pixel để giữa các đốt rắn có khe hở, nhìn rõ từng đốt. Đầu rắn màu xanh đậm hơn thân. Kích thước và tọa độ trong SFML 3 là sf::Vector2f, viết gọn bằng dấu ngoặc nhọn {x, y}, và là số thực nên có hậu tố .f.
Hàm ve là const: vẽ thì không được thay đổi trạng thái game, compiler sẽ báo lỗi nếu lỡ tay sửa.
Hàm main: cửa sổ và xử lý phím
int main()
{
sf::RenderWindow cuaSo(sf::VideoMode({SO_COT * O, SO_HANG * O}), "Ran san moi");
cuaSo.setFramerateLimit(60);
sf::Font font;
if (!font.openFromFile("font.ttf"))
return 1;
TroChoi game;
sf::Clock dongHo;
const sf::Time NHIP = sf::milliseconds(120); // ran di 1 o moi 120 ms
while (cuaSo.isOpen())
{
while (const std::optional suKien = cuaSo.pollEvent())
{
if (suKien->is<sf::Event::Closed>())
cuaSo.close();
if (const auto* phim = suKien->getIf<sf::Event::KeyPressed>())
{
switch (phim->code)
{
case sf::Keyboard::Key::Up: game.doiHuong({0, -1}); break;
...
}
}
}
if (dongHo.getElapsedTime() >= NHIP)
{
game.capNhat();
dongHo.restart();
}
cuaSo.clear(sf::Color(25, 25, 35));
game.ve(cuaSo, font);
cuaSo.display();
}
return 0;
}
Đối chiếu với sơ đồ vòng lặp game:
- Sự kiện:
pollEvent()trả vềstd::optional, có sự kiện thì vòngwhilechạy, hết sự kiện thì trả về rỗng và vòng lặp dừng.is<...>()hỏi sự kiện có phải loại đó không,getIf<...>()trả về con trỏ tới dữ liệu của sự kiện (ở đây là phím nào) hoặcnullptrnếu không phải. - Cập nhật:
sf::Clockđo thời gian từ lầnrestart()gần nhất. Đủ 120 ms thì gọicapNhat()một lần. - Vẽ:
clearxóa bằng màu nền,vevẽ vào bộ đệm ẩn,displaymới đưa cả khung hình lên màn hình. Nhờ vậy người chơi không bao giờ thấy hình vẽ dở, màn hình không bị nháy. setFramerateLimit(60)cho vòng lặp ngủ một chút mỗi vòng, nếu không game sẽ chạy hàng nghìn vòng mỗi giây và ăn 100% một nhân CPU chỉ để vẽ lại cùng một hình.
Lưu ý: Đừng gọi capNhat() mỗi vòng lặp rồi dùng sleep để làm chậm rắn. Ngủ trong vòng lặp game làm phím bấm bị trễ, và tốc độ game lại phụ thuộc thời gian vẽ của từng máy. Đo thời gian bằng đồng hồ như trên là cách các game engine vẫn làm.
Code đầy đủ game rắn săn mồi C++
#include <SFML/Graphics.hpp>
#include <deque>
#include <optional>
#include <random>
#include <string>
constexpr int O = 30; // kich thuoc moi o, pixel
constexpr int SO_COT = 20;
constexpr int SO_HANG = 15;
struct ViTri
{
int x, y;
bool operator==(const ViTri& b) const { return x == b.x && y == b.y; }
};
class TroChoi
{
public:
TroChoi() : sinhSo_(std::random_device{}()) { batDauLai(); }
void batDauLai()
{
ran_ = {{5, 7}, {4, 7}, {3, 7}}; // dau ran o phan tu dau tien
huong_ = huongMoi_ = {1, 0};
diem_ = 0;
thua_ = false;
datMoi();
}
void doiHuong(ViTri h)
{
if (h.x != -huong_.x || h.y != -huong_.y) // khong cho quay dau 180 do
huongMoi_ = h;
}
void capNhat() // goi moi "nhip" cua game
{
if (thua_)
return;
huong_ = huongMoi_;
ViTri dau = {ran_.front().x + huong_.x, ran_.front().y + huong_.y};
bool dungTuong = dau.x < 0 || dau.x >= SO_COT || dau.y < 0 || dau.y >= SO_HANG;
bool canThan = false;
for (const ViTri& p : ran_)
if (p == dau)
canThan = true;
if (dungTuong || canThan)
{
thua_ = true;
return;
}
ran_.push_front(dau);
if (dau == moi_)
{
diem_ += 10;
datMoi(); // an moi: giu nguyen duoi, ran dai them
}
else
{
ran_.pop_back(); // khong an: bo duoi, ran di chuyen
}
}
void ve(sf::RenderWindow& cuaSo, const sf::Font& font) const
{
sf::RectangleShape o({O - 2.f, O - 2.f});
o.setFillColor(sf::Color(220, 60, 60));
o.setPosition({moi_.x * O + 1.f, moi_.y * O + 1.f});
cuaSo.draw(o);
for (std::size_t i = 0; i < ran_.size(); i++)
{
o.setFillColor(i == 0 ? sf::Color(40, 160, 70) : sf::Color(90, 200, 110));
o.setPosition({ran_[i].x * O + 1.f, ran_[i].y * O + 1.f});
cuaSo.draw(o);
}
sf::Text chu(font, "Diem: " + std::to_string(diem_), 18);
chu.setPosition({8.f, 4.f});
cuaSo.draw(chu);
if (thua_)
{
sf::Text ketThuc(font, "THUA! Nhan Space de choi lai", 26);
ketThuc.setPosition({110.f, SO_HANG * O / 2 - 20.f});
cuaSo.draw(ketThuc);
}
}
bool thua() const { return thua_; }
private:
void datMoi()
{
std::uniform_int_distribution<int> cot(0, SO_COT - 1), hang(0, SO_HANG - 1);
while (true)
{
moi_ = {cot(sinhSo_), hang(sinhSo_)};
bool trungRan = false;
for (const ViTri& p : ran_)
if (p == moi_)
trungRan = true;
if (!trungRan)
return;
}
}
std::deque<ViTri> ran_;
ViTri huong_, huongMoi_, moi_;
int diem_ = 0;
bool thua_ = false;
std::mt19937 sinhSo_;
};
int main()
{
sf::RenderWindow cuaSo(sf::VideoMode({SO_COT * O, SO_HANG * O}), "Ran san moi");
cuaSo.setFramerateLimit(60);
sf::Font font;
if (!font.openFromFile("font.ttf"))
return 1;
TroChoi game;
sf::Clock dongHo;
const sf::Time NHIP = sf::milliseconds(120); // ran di 1 o moi 120 ms
while (cuaSo.isOpen())
{
while (const std::optional suKien = cuaSo.pollEvent())
{
if (suKien->is<sf::Event::Closed>())
cuaSo.close();
if (const auto* phim = suKien->getIf<sf::Event::KeyPressed>())
{
switch (phim->code)
{
case sf::Keyboard::Key::Up: game.doiHuong({0, -1}); break;
case sf::Keyboard::Key::Down: game.doiHuong({0, 1}); break;
case sf::Keyboard::Key::Left: game.doiHuong({-1, 0}); break;
case sf::Keyboard::Key::Right: game.doiHuong({1, 0}); break;
case sf::Keyboard::Key::Space: if (game.thua()) game.batDauLai(); break;
case sf::Keyboard::Key::Escape: cuaSo.close(); break;
default: break;
}
}
}
if (dongHo.getElapsedTime() >= NHIP)
{
game.capNhat();
dongHo.restart();
}
cuaSo.clear(sf::Color(25, 25, 35));
game.ve(cuaSo, font);
cuaSo.display();
}
return 0;
}
Biên dịch và chạy
Biên dịch như các bài trước, thêm 3 thư viện của SFML ở cuối lệnh. Thứ tự -lsfml-graphics -lsfml-window -lsfml-system là bắt buộc: thư viện dùng thư viện khác phải đứng trước thư viện bị dùng.
Chạy ./ran_san_moi (Windows là ran_san_moi.exe), cửa sổ game hiện ra. Dùng 4 phím mũi tên để lái rắn, Esc để thoát. Ảnh đầu bài là lúc rắn đã ăn 3 mồi. Đâm vào tường thì game dừng và hiện dòng thông báo, bấm Space để chơi lại:
Nếu chạy mà cửa sổ không hiện và chương trình thoát luôn, kiểm tra file font.ttf đã nằm cùng thư mục chạy chương trình chưa: openFromFile thất bại thì main trả về 1.
Hướng mở rộng
- Tăng tốc theo điểm: mỗi 50 điểm giảm
NHIPđi 10 ms, game khó dần. - Lưu điểm cao nhất ra file như bài đọc ghi file, hiện “Ky luc” ở góc màn hình.
- Đi xuyên tường: thay vì thua, đầu rắn ra khỏi cột 19 thì xuất hiện lại ở cột 0. Gợi ý:
(x + SO_COT) % SO_COT. - Hàng đợi phím: lưu tối đa 2 phím bấm bằng một
std::deque<ViTri>, mỗi nhịp lấy ra một phím, để bấm nhanh Lên rồi Trái được thực hiện đủ cả hai lượt rẽ. - Tách trạng thái game thành Menu, Đang chơi, Tạm dừng, Thua bằng State pattern ở chương trước.
- Âm thanh: SFML có module
sf::Soundđể phát tiếng khi ăn mồi, link thêm-lsfml-audio.
Kết
Game rắn săn mồi C++ cho thấy cấu trúc của mọi game: vòng lặp gồm xử lý sự kiện, cập nhật theo nhịp thời gian và vẽ lại toàn bộ khung hình. Logic game gói trong một class, tách khỏi phần đồ họa. std::deque với push_front và pop_back làm phần di chuyển và mọc dài gọn tới mức chỉ cần một câu if. SFML lo phần cửa sổ, phím bấm và vẽ, với API hướng đối tượng rất quen thuộc.
Tài liệu đầy đủ của SFML có ở trang hướng dẫn SFML. Bài tiếp theo chúng ta làm game cờ caro có máy chơi, dùng thuật toán minimax để máy tự tìm nước đi. Nếu thấy bài viết có ích, hãy chia sẻ cho bạn bè nhé.







