Bài này dạy gì: sáu ý tưởng gần như game nào cũng dùng, viết bằng C++ thuần để bạn thấy chúng thật ra chỉ là vài chục dòng: vòng lặp với bước vật lý cố định, ECS, máy trạng thái, event, hẹn giờ, và action thay cho phím. Cần biết trước: đọc và chạy được một chương trình C++ có struct, std::vector và lambda (xem Bài 4: C++ hơn C ở những gì và Bài 6: C++ hiện đại trong code njin).
Mỗi mục có một chương trình ngắn chạy thật, rồi phần Trong njin chỉ ra ý tưởng đó nằm ở đâu trong engine. Khi bắt đầu njin, bạn sẽ gặp lại từng thứ ở đây.
1. Vòng lặp và bước vật lý cố định
Game là một vòng lặp: đọc input, cập nhật thế giới, vẽ, rồi lặp lại. Câu hỏi là mỗi lần cập nhật thì thế giới tiến bao nhiêu thời gian. Cách đơn giản nhất là bằng thời gian của frame vừa rồi (dt). Cách này có một lỗi khó thấy: kết quả phụ thuộc FPS. Chương trình dưới đây cho một nhân vật nhảy lên, ở bốn mức FPS, bằng hai cách.
#include <cstdio>
#include <initializer_list>
constexpr float gravity = 1000.0f;
constexpr float jump_speed = 300.0f;
struct body {
float y = 0.0f;
float v = jump_speed;
};
void step(body &b, float dt) {
b.v -= gravity * dt;
b.y += b.v * dt;
}
float peak_variable(float fps) {
const float frame = 1.0f / fps;
body b;
float peak = 0.0f;
for (float t = 0.0f; t < 1.0f; t += frame) {
step(b, frame);
if (b.y > peak)
peak = b.y;
}
return peak;
}
float peak_fixed(float fps) {
const float frame = 1.0f / fps;
const float fixed_dt = 1.0f / 60.0f;
const int max_steps = 8;
body b;
float peak = 0.0f;
float accumulator = 0.0f;
for (float t = 0.0f; t < 1.0f; t += frame) {
accumulator += frame;
int steps = 0;
while (accumulator >= fixed_dt && steps < max_steps) {
step(b, fixed_dt);
accumulator -= fixed_dt;
steps++;
if (b.y > peak)
peak = b.y;
}
if (steps == max_steps && accumulator >= fixed_dt)
accumulator = 0.0f;
}
return peak;
}
int main() {
std::printf("%6s %-16s %-16s\n", "FPS", "bước = frame", "bước cố định");
for (const float fps : {20.0f, 30.0f, 60.0f, 144.0f})
std::printf("%6.0f %-16.2f %-16.2f\n", fps, peak_variable(fps), peak_fixed(fps));
}
Chạy (g++ -std=c++20 -Wall -Wextra -Wpedantic):
FPS bước = frame bước cố định
20 37.50 42.50
30 40.00 42.50
60 42.50 42.50
144 43.96 42.50
Cùng một cú nhảy, cột trái cao từ 37,5 đến 44 pixel tùy máy (ở 240 FPS là 44,38). Nguyên nhân: mỗi bước là một xấp xỉ, và bước càng dài thì sai càng nhiều. Cột phải luôn 42,5: vật lý chỉ chạy theo bước cố định (1/60 giây), còn số bước mỗi frame thì đổi. Vòng while gom thời gian trong accumulator rồi chạy đủ số bước. max_steps chặn một frame quá dài (ví dụ dừng ở breakpoint) để game không phải chạy dồn hàng trăm bước.
- Ghi chú
- Frame nhanh hơn 60 FPS thì vài frame liền không có bước vật lý nào, nên vị trí trên màn hình đứng yên rồi giật. Cách chữa là nội suy: vẽ ở giữa vị trí trước và sau, theo phần nhịp còn dư alpha = accumulator / fixed_dt. Đây là bài tập 2 ở cuối.
Trong njin:
2. Entity, component, system
Cách nghĩ theo kế thừa ("Quái là một Nhân vật là một Vật thể") gãy nhanh khi game lớn: một cái bẫy vừa có máu vừa không di chuyển thì nằm ở đâu? ECS đảo lại: một entity chỉ là một con số định danh, component là dữ liệu gắn vào nó, system là một hàm duyệt những entity có đúng các component nó cần. Muốn một vật có máu, bạn gắn health cho nó, không viết lớp mới.
#include <cstdint>
#include <cstdio>
#include <unordered_map>
#include <vector>
using entity = std::uint32_t;
template <class T> struct pool {
std::unordered_map<entity, T> items;
};
struct registry {
entity next = 1;
std::vector<entity> alive;
template <class T> pool<T> &of() {
static pool<T> p;
return p;
}
entity create() {
alive.push_back(next);
return next++;
}
template <class T> T &emplace(entity e, T value) { return of<T>().items[e] = value; }
template <class T> T &get(entity e) { return of<T>().items.at(e); }
template <class T> bool has(entity e) { return of<T>().items.count(e) != 0; }
template <class A, class B, class F> void each(F f) {
for (const entity e : alive)
if (has<A>(e) && has<B>(e))
f(e, get<A>(e), get<B>(e));
}
};
struct position { float x = 0, y = 0; };
struct velocity { float x = 0, y = 0; };
struct health { int hp = 3; };
struct enemy_tag {};
void move_system(registry ®, float dt) {
reg.each<position, velocity>([&](entity, position &p, velocity &v) {
p.x += v.x * dt;
p.y += v.y * dt;
});
}
int main() {
registry reg;
const entity player = reg.create();
reg.emplace(player, position{0, 0});
reg.emplace(player, velocity{10, 0});
reg.emplace(player, health{5});
const entity rock = reg.create();
reg.emplace(rock, position{4, 4});
const entity slime = reg.create();
reg.emplace(slime, position{8, 0});
reg.emplace(slime, velocity{-5, 0});
reg.emplace(slime, health{2});
reg.emplace(slime, enemy_tag{});
move_system(reg, 0.5f);
for (const entity e : reg.alive) {
const position &p = reg.get<position>(e);
std::printf("entity %u: (%.1f, %.1f)%s%s\n", e, p.x, p.y, reg.has<health>(e) ? " có máu" : "",
reg.has<enemy_tag>(e) ? " [quái]" : "");
}
}
Chạy:
entity 1: (5.0, 0.0) có máu
entity 2: (4.0, 4.0)
entity 3: (5.5, 0.0) có máu [quái]
Hòn đá (entity 2) có vị trí nhưng không có velocity, nên move_system bỏ qua nó. Không ai phải viết if (là_đá). enemy_tag không chứa dữ liệu nào: nó chỉ để đánh dấu.
- Ghi chú
- Chương trình này giữ kho component trong một biến static cho ngắn, nên hai registry sẽ dùng chung kho. Một ECS thật giữ kho bên trong registry, và lưu component liền nhau trong bộ nhớ để duyệt nhanh.
Trong njin: njin dùng thư viện EnTT, njin::world(ctx) trả về entt::registry. Cách dùng y hệt: registry.create(), registry.emplace<T>(e, ...), registry.view<A, B>().each(...). Xem Entity, component và event.
3. Máy trạng thái
Nhân vật đang đứng yên, chạy hay nhảy? Dùng một enum class và một chỗ duy nhất đổi trạng thái, để việc "khi vào" và "khi ra" khỏi trạng thái có nơi để đặt (phát tiếng, đổi animation, đặt lại bộ đếm).
#include <cstdio>
#include <functional>
#include <vector>
enum class state { idle, run, jump };
const char *name(state s) {
switch (s) {
case state::idle: return "idle";
case state::run: return "run";
case state::jump: return "jump";
}
return "?";
}
struct character {
state current = state::idle;
float time_in_state = 0.0f;
void change(state next) {
if (next == current)
return;
std::printf(" %s -> %s (sau %.2f giây)\n", name(current), name(next), time_in_state);
current = next;
time_in_state = 0.0f;
}
void update(float dt, bool on_ground, float move_x) {
time_in_state += dt;
if (!on_ground)
change(state::jump);
else if (move_x != 0.0f)
change(state::run);
else
change(state::idle);
}
};
struct timers {
struct item {
float left;
std::function<void()> fn;
};
std::vector<item> items;
void after(float seconds, std::function<void()> fn) { items.push_back({seconds, std::move(fn)}); }
void update(float dt) {
std::vector<std::function<void()>> due;
for (item &t : items)
t.left -= dt;
for (std::size_t i = 0; i < items.size();) {
if (items[i].left <= 0.0f) {
due.push_back(std::move(items[i].fn));
items.erase(items.begin() + (std::ptrdiff_t)i);
} else {
i++;
}
}
for (auto &fn : due)
fn();
}
};
int main() {
character hero;
timers clock;
float t = 0.0f;
clock.after(0.25f, [&] { std::printf(" [%.2f] bắt đầu chạy\n", t); });
clock.after(0.75f, [&] {
std::printf(" [%.2f] hẹn giờ trong hẹn giờ: 0.25 giây nữa nhảy\n", t);
clock.after(0.25f, [&] { std::printf(" [%.2f] nhảy!\n", t); });
});
const float dt = 0.0625f;
for (int frame = 0; frame < 24; frame++) {
t += dt;
const bool running = t >= 0.25f;
const bool in_air = t >= 1.0f && t < 1.25f;
hero.update(dt, !in_air, running ? 1.0f : 0.0f);
clock.update(dt);
}
}
Chạy:
idle -> run (sau 0.25 giây)
[0.25] bắt đầu chạy
[0.75] hẹn giờ trong hẹn giờ: 0.25 giây nữa nhảy
run -> jump (sau 0.75 giây)
[1.00] nhảy!
jump -> run (sau 0.25 giây)
Đừng rải state = state::run; khắp code: mọi lần đổi đi qua change(), nên log (dòng có mũi tên) và mọi hiệu ứng đi kèm đều nằm ở một chỗ.
Trong njin: cả game cũng là một máy trạng thái: menu, màn chơi, game over là các scene (njin::scene_register(), njin::scene_set(), xem Scene). Animation chuyển idle, run, jump là một đồ thị trạng thái (njin::anim_graph_create(), xem Animation và máy trạng thái).
4. Hẹn giờ
"Sau 2 giây thì hồi sinh", "cứ mỗi 3 giây sinh một con quái". Đừng tự giữ một biến đếm cho mỗi việc. Một danh sách hẹn giờ (nửa dưới của chương trình trên) lo hết. Có hai điểm dễ sai và chương trình đã tránh:
- hàm được gọi có thể tạo hẹn giờ mới (dòng "hẹn giờ trong hẹn giờ"), nên phải gom hàm đã đến hạn ra một danh sách riêng, rồi mới gọi;
- đếm giờ bằng số thực cộng dồn thì lệch (0.05 cộng nhiều lần không ra đúng 0.25), nên ví dụ dùng dt = 1/16.
Trong njin: njin::timer_after() và njin::timer_every() làm đúng việc này. Hàm chạy trong phase_update, và chạy được cả những việc thêm hay hủy entity. Xem Màn hình ảo, hẹn giờ và tween.
5. Event
Khi quái chết, cần cộng điểm, phát tiếng, thả vật phẩm. Nếu code của quái gọi từng thứ đó, quái phải biết tất cả. Với event, quái chỉ báo "tôi chết rồi"; ai quan tâm thì đăng ký nghe.
#include <cstdio>
#include <functional>
#include <map>
#include <string>
#include <vector>
struct enemy_died {
int id;
int score;
};
struct bus {
std::vector<std::function<void(const enemy_died &)>> listeners;
std::vector<enemy_died> queue;
void listen(std::function<void(const enemy_died &)> f) { listeners.push_back(std::move(f)); }
void enqueue(enemy_died e) { queue.push_back(e); }
void flush() {
std::vector<enemy_died> now;
now.swap(queue);
for (const enemy_died &e : now)
for (auto &f : listeners)
f(e);
}
};
enum class key {
space, w, up, left, a };
struct actions {
std::map<std::string, std::vector<key>> binds;
std::vector<key> down;
void bind(const std::string &action, std::vector<key> keys) { binds[action] = std::move(keys); }
bool pressed(const std::string &action) const {
const auto it = binds.find(action);
if (it == binds.end())
return false;
for (const key k : it->second)
for (const key d : down)
if (k == d)
return true;
return false;
}
};
int main() {
int score = 0;
events.listen([&](
const enemy_died &e) { score += e.score; });
events.listen([](
const enemy_died &e) { std::printf(
" âm thanh: quái %d chết\n", e.id); });
std::printf("trước flush: điểm = %d\n", score);
std::printf("sau flush: điểm = %d\n", score);
actions in;
in.bind("jump", {key::space, key::w, key::up});
in.down = {key::w};
std::printf("bấm W: jump = %s\n", in.pressed("jump") ? "có" : "không");
in.down = {key::left};
std::printf("bấm mũi tên trái: jump = %s\n", in.pressed("jump") ? "có" : "không");
in.bind("jump", {key::left});
std::printf("sau khi đổi phím: jump = %s\n", in.pressed("jump") ? "có" : "không");
}
entt::dispatcher & events(context &ctx)
Trả về dispatcher của EnTT để gửi và nhận event giữa các system.
@ space
Khoảng trống dọc.
Definition njin_ui_layout.h:20
Chạy:
trước flush: điểm = 0
âm thanh: quái 1 chết
âm thanh: quái 2 chết
sau flush: điểm = 350
bấm W: jump = có
bấm mũi tên trái: jump = không
sau khi đổi phím: jump = có
Điểm là 0 trước flush() và 350 sau đó: event được xếp hàng rồi phát ở một chỗ cố định của frame. Nhờ vậy gọi enqueue giữa lúc đang duyệt danh sách quái không làm hỏng danh sách đó.
Trong njin: njin::events() là một entt::dispatcher. enqueue xếp hàng và event được phát ngay sau phase_post_update; trigger phát ngay lập tức. Xem Entity, component và event.
6. Action thay cho phím
Nửa sau của chương trình trên. Code game hỏi pressed("jump"), không hỏi phím Space. Bảng nối "jump" với các phím nằm ở một chỗ: thêm phím W, thêm tay cầm, hay cho người chơi đổi phím đều chỉ là sửa bảng đó, và không dòng logic nào đổi.
Trong njin: njin::action_define(ctx, "jump", {njin::key_space, njin::key_w, njin::pad_face_down}), rồi njin::action_pressed(). Xem Nhập liệu.
Một mẫu nữa: handle thay cho con trỏ
Ở nhiều chỗ của njin bạn sẽ thấy một struct chỉ chứa một con số id, trong đó id == 0 nghĩa là "không hợp lệ" (njin::timer_handle, njin::texture_handle, njin::sound_handle...). Engine giữ tài nguyên thật ở trong, còn game chỉ cầm số. Không có con trỏ treo, và hủy tài nguyên rồi thì số cũ chỉ thành "không hợp lệ" chứ không làm chương trình sập. Bài Bài 5: Giá trị, tham chiếu và quyền sở hữu dựng một kho như vậy.
Tự kiểm tra
- Vì sao vật lý dùng bước cố định mà vẽ thì không?
- max_steps trong vòng lặp cố định để làm gì? Chuyện gì xảy ra nếu bỏ nó, và game bị đứng nửa giây?
- Trong ECS, muốn một vật vừa di chuyển vừa có máu thì bạn làm gì? Còn nếu dùng kế thừa?
- Vì sao enqueue an toàn hơn gọi thẳng người nghe khi đang duyệt danh sách quái?
- Vì sao đếm giờ bằng cách cộng 0.05f nhiều lần dễ lệch?
Bài tập
- Trong learn_gp_loop.cpp, thêm FPS 240 vào danh sách. Cột "bước cố định" có đổi không? Vì sao?
- Thêm nội suy vào vòng lặp cố định: lưu prev_y trước mỗi bước, và mỗi frame tính vị trí vẽ prev_y + (y - prev_y) * alpha. Đếm xem ở 144 FPS, y đổi ở bao nhiêu frame và vị trí vẽ đổi ở bao nhiêu frame.
- Thêm vào registry của mini ECS một hàm each<A>(f) duyệt entity có component A. Rồi dùng nó để đếm số entity có health và tổng máu.
Đáp án
Tự kiểm tra
- Vật lý cần cho ra cùng kết quả ở mọi FPS và không để vật xuyên tường khi FPS tụt, nên phải đi theo bước cố định. Vẽ chỉ cần kịp và mượt: vẽ mỗi frame, kèm nội suy nếu cần.
- Nó chặn số bước vật lý tối đa trong một frame. Không có nó, sau khi game đứng nửa giây (30 bước dồn lại), frame kế tiếp phải chạy cả 30 bước, mất nhiều thời gian hơn, làm frame sau nữa lại dồn thêm: game càng lúc càng chậm.
- ECS: gắn cả velocity và health cho entity. Kế thừa: phải có một lớp thừa kế cả hai (đa kế thừa) hoặc một lớp mới cho riêng tổ hợp đó, và số tổ hợp tăng rất nhanh.
- enqueue chỉ ghi vào hàng đợi, nên danh sách quái và danh sách người nghe không đổi lúc đang duyệt. Việc phát event diễn ra sau, ở một chỗ cố định của frame.
- 0.05 không biểu diễn chính xác được trong số thực nhị phân, nên sai số nhỏ cộng dồn qua nhiều lần. Dùng giá trị chia chính xác (như 1/16), đếm bằng số nguyên, hoặc so sánh có dung sai.
Bài tập
- Cột "bước cố định" không đổi, vẫn 42.50, còn cột "bước = frame" thành 44.38. Số bước vật lý chỉ phụ thuộc thời gian trôi qua (floor(t / (1/60))), không phụ thuộc cách chia thời gian đó thành các frame. FPS chỉ đổi số bước chạy trong mỗi frame.
Chương trình đầy đủ:
#include <cstdio>
constexpr float gravity = 1000.0f, jump_speed = 300.0f;
struct body { float y = 0.0f, v = jump_speed, prev_y = 0.0f; };
void step(body &b, float dt) { b.prev_y = b.y; b.v -= gravity * dt; b.y += b.v * dt; }
int main() {
const float fps = 144.0f, frame = 1.0f / fps, fixed_dt = 1.0f / 60.0f;
body b;
float accumulator = 0.0f;
int frames = 0, y_changed = 0, draw_changed = 0;
float last_y = 0.0f, last_draw = 0.0f;
bool between = true;
for (float t = 0.0f; t < 0.5f; t += frame) {
accumulator += frame;
while (accumulator >= fixed_dt) { step(b, fixed_dt); accumulator -= fixed_dt; }
const float alpha = accumulator / fixed_dt;
const float draw_y = b.prev_y + (b.y - b.prev_y) * alpha;
const float lo = b.prev_y < b.y ? b.prev_y : b.y, hi = b.prev_y < b.y ? b.y : b.prev_y;
if (draw_y < lo - 1e-4f || draw_y > hi + 1e-4f) between = false;
if (b.y != last_y) y_changed++;
if (draw_y != last_draw) draw_changed++;
last_y = b.y; last_draw = draw_y; frames++;
}
std::printf("frame: %d, y đổi ở %d frame, draw_y đổi ở %d frame, draw_y luôn nằm giữa prev và y: %s\n",
frames, y_changed, draw_changed, between ? "đúng" : "SAI");
}
In ra frame: 72, y đổi ở 28 frame, draw_y đổi ở 69 frame, draw_y luôn nằm giữa prev và y: đúng. Vị trí vật lý y chỉ đổi ở 28 trong 72 frame (các frame còn lại không có bước nào), nên nhìn giật. Vị trí vẽ đổi ở hầu như mọi frame và luôn nằm giữa hai vị trí vật lý. Đó là cái giá của nội suy: vị trí vẽ trễ một bước.
Thêm vào struct registry:
template <class A, class F> void each(F f) {
for (const entity e : alive)
if (has<A>(e))
f(e, get<A>(e));
}
Dùng: reg.each<health>([&](entity, health &h) { count++; total += h.hp; });. Với ba entity (hai có máu 5 và 2, một không có) in ra 2 entity có máu, tổng 7 máu.
Mọi đáp án trên đã biên dịch và chạy với g++ -std=c++20 -Wall -Wextra -Wpedantic.
Bước tiếp theo
Bạn đã đi hết phần kiến thức nền. Từ đây: