njin
Engine 2D gọn nhẹ để tự tay làm game theo ý mình. Vibe coding cũng ổn luôn
Loading...
Searching...
No Matches
Va chạm

Toán học, va chạm, số ngẫu nhiên có các hàm hình học để tự kiểm tra hai hình. Trang này là tầng trên: gắn njin::collider vào entity, engine tự tìm các cặp chạm nhau và báo cho game bằng event. Kèm theo là di chuyển có vật cản, truy vấn vùng và bắn tia.

Đây không phải mô phỏng vật lý: không có lực, khối lượng, nảy hay xếp chồng. Nó dành cho những thứ game hành động cần: đạn trúng quái, nhặt đồ, vùng sát thương, tường chắn, quái nhìn thấy người chơi.

#include <njin.h>
namespace {
// Đặt tên cho các lớp va chạm một lần, dùng khắp game.
constexpr njin::u32 layer_player = njin::layer_bit(0);
constexpr njin::u32 layer_wall = njin::layer_bit(1);
constexpr njin::u32 layer_enemy = njin::layer_bit(2);
constexpr njin::u32 layer_pickup = njin::layer_bit(3);
constexpr njin::u32 layer_player_shot = njin::layer_bit(4);
struct player {};
struct coin {};
struct bullet {
njin::vec2 velocity{};
};
struct enemy {
njin::i32 hp = 3;
};
entt::entity hero = entt::null;
njin::i32 score = 0;
// Mỗi cặp va chạm gửi hai event, một cho mỗi bên: chỉ cần xét `self`.
void on_enter(njin::context &ctx, njin::collision_enter &e) {
entt::registry &reg = njin::world(ctx);
// Một handler trước đó trong cùng frame có thể đã hủy một trong hai.
if (!reg.valid(e.self) || !reg.valid(e.other))
return;
if (reg.all_of<coin>(e.self) && reg.all_of<player>(e.other)) {
score++;
reg.destroy(e.self); // an toàn: event phát sau phase_post_update
}
if (reg.all_of<bullet>(e.self)) {
if (enemy *foe = reg.try_get<enemy>(e.other); foe != nullptr && --foe->hp <= 0)
reg.destroy(e.other);
reg.destroy(e.self); // đạn biến mất khi trúng quái hoặc tường
}
}
void spawn(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
njin::events(ctx).sink<njin::collision_enter>().connect<&on_enter>(ctx);
hero = reg.create();
reg.emplace<player>(hero);
reg.emplace<njin::transform>(hero, njin::transform{.pos = {100, 300}});
reg.emplace<njin::collider>(hero, njin::collider{.size = {24, 32}, .layer = layer_player});
// Tường: vật cản thường.
const entt::entity wall = reg.create();
reg.emplace<njin::transform>(wall, njin::transform{.pos = {400, 300}});
reg.emplace<njin::collider>(wall, njin::collider{.size = {32, 300}, .layer = layer_wall});
// Đồng xu: trigger, chỉ để ý người chơi.
const entt::entity c = reg.create();
reg.emplace<coin>(c);
reg.emplace<njin::transform>(c, njin::transform{.pos = {250, 300}});
reg.emplace<njin::collider>(c, njin::collider{.shape = njin::collider_circle, .radius = 8,
.layer = layer_pickup, .mask = layer_player,
.trigger = true});
const entt::entity foe = reg.create();
reg.emplace<enemy>(foe);
reg.emplace<njin::transform>(foe, njin::transform{.pos = {600, 300}});
reg.emplace<njin::collider>(foe, njin::collider{.size = {32, 32}, .layer = layer_enemy});
njin::collision_set_debug(ctx, true); // vẽ khung collider để dò lỗi
}
void move_hero(njin::context &ctx) {
njin::vec2 dir{};
if (njin::key_held(ctx, njin::key_a)) dir.x -= 1;
if (njin::key_held(ctx, njin::key_d)) dir.x += 1;
if (njin::key_held(ctx, njin::key_w)) dir.y -= 1;
if (njin::key_held(ctx, njin::key_s)) dir.y += 1;
// Dừng ở tường, trượt dọc theo nó; đi xuyên qua đồng xu (trigger).
njin::collision_move(ctx, hero, njin::normalize(dir) * 200.0f * njin::delta(ctx));
}
void shoot(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
const entt::entity b = reg.create();
reg.emplace<bullet>(b, bullet{.velocity = {600, 0}});
reg.emplace<njin::transform>(b, reg.get<njin::transform>(hero));
// Đạn của người chơi: chỉ trúng quái và tường, không trúng người bắn.
reg.emplace<njin::collider>(b, njin::collider{.size = {6, 6}, .layer = layer_player_shot,
.mask = layer_enemy | layer_wall,
.trigger = true});
}
const njin::f32 dt = njin::delta(ctx);
for (auto [e, b, tr] : reg.view<bullet, njin::transform>().each())
tr.pos += b.velocity * dt;
// Quái chỉ bắn khi thấy người chơi: tia bị tường chặn thì không thấy.
for (auto [e, foe, tr] : reg.view<enemy, njin::transform>().each()) {
const njin::vec2 target = reg.get<njin::transform>(hero).pos;
const njin::raycast_hit sight =
njin::collision_raycast(ctx, tr.pos, target, layer_wall | layer_player, false, e);
if (sight.hit && sight.entity == hero) {
// ... quái bắn về phía người chơi
}
}
}
void setup(njin::context &ctx) {
}
} // namespace
njin::mod_desc collision_example_module() { return {.name = "collision", .setup = setup}; }
constexpr u32 layer_bit(i32 n)
Bit của lớp va chạm thứ n (0..31), để đặt tên lớp cho dễ đọc: constexpr u32 layer_enemy = njin::layer...
Definition njin_collision.h:21
void collision_set_debug(context &ctx, bool on)
Vẽ khung mọi collider hộp và tròn trong phase_render, trên sprite: xanh lá cho vật cản,...
collision_move_result collision_move(context &ctx, entt::entity entity, vec2 delta)
Di chuyển một entity có collider, dừng lại khi đụng collider không phải trigger (kể cả ô của collider...
raycast_hit collision_raycast(const context &ctx, vec2 from, vec2 to, u32 mask=layer_all, bool include_triggers=false, entt::entity ignore=entt::null)
Bắn một tia từ from đến to, trả về vật trúng gần nhất: tầm nhìn của quái, đạn tức thời,...
@ collider_circle
Hình tròn bán kính radius.
Definition njin_collision.h:26
const char * tr(const context &ctx, const char *key)
Chuỗi đã dịch của key trong ngôn ngữ đang dùng.
bool key_pressed(const context &ctx, key_code key)
Đúng ở frame mà phím vừa được nhấn xuống (chỉ một frame).
bool key_held(const context &ctx, key_code key)
Đúng khi phím đã được giữ từ frame trước và vẫn đang giữ.
@ key_s
Phím S.
Definition _types.h:362
@ key_d
Phím D.
Definition _types.h:347
@ key_a
Phím A.
Definition _types.h:344
@ key_w
Phím W.
Definition _types.h:366
@ key_j
Phím J.
Definition _types.h:353
vec2 normalize(vec2 v)
Vector cùng hướng, độ dài 1.
Definition _math.h:74
void ecs_register(context &ctx, sys_phase phase, sys_fnc fnc, const char *name=nullptr)
Thêm một system vào lịch chạy của một phase.
entt::dispatcher & events(context &ctx)
Trả về dispatcher của EnTT để gửi và nhận event giữa các system.
entt::registry & world(context &ctx)
Trả về registry của EnTT chứa mọi entity và component của game.
@ phase_update
Mỗi frame, logic chính của game.
Definition _mod.h:35
@ phase_startup
Một lần, trước frame đầu tiên.
Definition _mod.h:32
@ phase_fixed_update
Theo nhịp cố định, 0 hoặc nhiều lần mỗi frame. Cho vật lý.
Definition _mod.h:34
f32 delta(const context &ctx)
Thời gian của frame trước, tính bằng giây, đã nhân với tốc độ thời gian.
std::int32_t i32
Số nguyên có dấu 32 bit.
Definition _types.h:14
float f32
Số thực 32 bit.
Definition _types.h:28
std::uint32_t u32
Số nguyên không dấu 32 bit.
Definition _types.h:22
Hình va chạm của một entity.
Definition njin_collision.h:46
Hai collider bắt đầu chồng nhau.
Definition njin_collision.h:78
entt::entity self
Entity nhận event.
Definition njin_collision.h:79
entt::entity other
Entity kia.
Definition njin_collision.h:80
Mô tả một module: một nhóm system có tên.
Definition _mod.h:67
Kết quả của collision_raycast().
Definition njin_collision.h:223
Vị trí, góc xoay và tỉ lệ của một entity trong thế giới.
Definition _comps.h:14
Vector 2 chiều. Dùng cho vị trí, kích thước, độ dời.
Definition _types.h:39
f32 y
Thành phần dọc (trong không gian màn hình, y tăng khi đi xuống).
Definition _types.h:41
f32 x
Thành phần ngang.
Definition _types.h:40

Collider

Trường Ý nghĩa
shape collider_box (hình hộp thẳng trục), collider_circle, hoặc collider_tiles
size, radius Kích thước hộp, bán kính tròn
offset Độ lệch của tâm so với transform.pos
layer, mask Thuộc lớp nào, va chạm với lớp nào
trigger Chỉ báo chồng nhau, không chặn đường
enabled Tắt tạm mà không gỡ component

offset, size và radius nhân với transform.scale; offset xoay theo transform.rot. Bản thân hình hộp luôn thẳng trục, không xoay.

Collider có thể nằm trên entity con (njin::child_of), ví dụ hitbox ở đầu kiếm: module collision chạy sau module hierarchy nên thấy đúng vị trí của frame này.

Lớp và mặt nạ

Mỗi collider thuộc một hoặc nhiều lớp (layer, tối đa 32, đặt tên bằng njin::layer_bit()) và chọn va chạm với những lớp nào (mask). Hai collider A và B chỉ được xét khi cả hai chiều đều cho phép:

(A.mask & B.layer) != 0 và (B.mask & A.layer) != 0

Ví dụ đạn của người chơi có mask = layer_enemy | layer_wall: nó không bao giờ trúng người chơi, dù người chơi có mask = layer_all. Đây cũng là quy tắc của Box2D.

Event

Mỗi frame, trong phase_post_update, engine tìm mọi cặp collider hộp hoặc tròn đang chồng nhau và xếp hàng event qua njin::events():

Event Khi nào
njin::collision_enter Frame đầu tiên hai collider chồng nhau
njin::collision_stay Mỗi frame sau đó, khi vẫn còn chồng nhau
njin::collision_exit Frame thôi chồng nhau: tách ra, bị tắt, bị gỡ collider, hoặc bị hủy
  • Mỗi cặp gửi hai event, một cho mỗi bên (self là bên nhận). Handler chỉ cần hỏi "`self` là gì, `other` là gì".
  • Event được phát sau phase_post_update, nên hủy entity ngay trong handler là an toàn. Nhưng mọi event của frame đã được xếp hàng trước đó: nếu hai viên đạn cùng trúng một con quái, handler thứ nhất hủy quái thì event thứ hai vẫn tới. Luôn kiểm tra registry.valid() cho cả self và other ở đầu handler.
  • exit chỉ gửi cho bên còn sống. other có thể đã bị hủy: kiểm tra registry.valid().
  • trigger trong event là true nếu một trong hai là trigger.

Tìm cặp dùng một lưới đều: chỉ những collider chung ô mới được so. Cỡ ô mặc định 64, chỉnh bằng njin::collision_set_cell_size() cho gần cỡ collider phổ biến của game. Trên máy thử, bản Release, 2000 hộp di chuyển mất khoảng 1 ms mỗi frame.

Di chuyển có vật cản

njin::collision_move() dời một entity, dừng trước collider không phải trigger và trước các ô của collider_tiles, rồi ghi vị trí mới vào transform:

const njin::collision_move_result r = njin::collision_move(ctx, hero, velocity * dt);
if (r.hit_y && velocity.y > 0)
on_ground = true;
if (r.hit_x)
velocity.x = 0;
Kết quả của collision_move().
Definition njin_collision.h:116
bool hit_x
Bị chặn trên trục ngang.
Definition njin_collision.h:118
bool hit_y
Bị chặn trên trục dọc. hit_y && delta.y > 0 là đang đứng trên đất.
Definition njin_collision.h:119
  • Đi trục ngang trước rồi trục dọc, nên trượt dọc theo tường.
  • Mỗi trục được quét liên tục: bước dài đến đâu cũng không xuyên qua tường mỏng.
  • Vật cản đang chồng sẵn lên entity bị bỏ qua, để entity bị kẹt vẫn đi ra được.
  • Bỏ qua collider của các entity con trực tiếp, để vũ khí không chặn chủ.
  • Hình tròn được di chuyển như hình hộp bao quanh nó.

Tilemap làm vật cản

Gắn một collider collider_tiles lên entity có njin::tilemap: mọi ô không trống thành vật cản cho njin::collision_move(), truy vấn và raycast, với layer/mask như collider thường.

reg.emplace<njin::collider>(level, njin::collider{.shape = njin::collider_tiles, .layer = layer_wall});
@ collider_tiles
Mọi ô không trống của njin::tilemap trên cùng entity là vật cản.
Definition njin_collision.h:29

Ô tilemap không sinh event va chạm. njin::tilemap_move() vẫn dùng được khi chỉ cần va chạm với một tilemap.

Truy vấn và raycast

Hàm Dùng cho
njin::collision_overlap_rect() Vùng đánh của một đòn chém
njin::collision_overlap_circle() Vùng nổ, tầm phát hiện
njin::collision_overlap_point() Chuột đang chỉ vào gì (dùng njin::scr2w())
njin::collision_raycast() Tầm nhìn, đạn tức thời, laser, kiểm tra đứng trên đất

Truy vấn chạy ngay khi gọi, trên vị trí hiện tại, và duyệt qua mọi collider: nhanh với vài trăm collider, nhưng đừng gọi hàng nghìn lần mỗi frame. Raycast trả về vật trúng gần nhất, mặc định bỏ qua trigger, và có tham số ignore để bỏ qua người bắn.

Đám đông: hàng xóm gần nhất và đẩy nhau ra

Collider hợp với vài trăm vật. Một bầy vài nghìn con quái hay một đám đông thì cần thứ khác: chúng không cần event, chỉ cần không chồng lên nhau và biết ai ở gần. Việc đó là của njin::spatial_index, trong njin_spatial.h:

Hàm Dùng cho
njin::spatial_build() Dựng lại index mỗi frame từ các hình tròn njin::spatial_item
njin::spatial_nearest() k vật gần một điểm nhất: tự ngắm, tìm bạn, quái gần nhất
njin::spatial_separate() Đẩy các vật chồng nhau ra, mỗi vật chỉ xét k vật chạm gần nhất
#include <njin.h>
#include <vector>
namespace {
struct zombie {
njin::vec2 velocity{};
};
entt::entity hero = entt::null;
njin::spatial_index index; // giữ qua các frame để dùng lại bộ nhớ
std::vector<entt::entity> who; // vật thứ i trong index là entity who[i]
std::vector<njin::spatial_item> items;
std::vector<njin::vec2> push;
entt::entity aimed = entt::null; // con quái người chơi đang ngắm
void startup(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
hero = reg.create();
reg.emplace<njin::transform>(hero, njin::transform{.pos = {640, 360}});
for (int i = 0; i < 2000; i++) {
const entt::entity z = reg.create();
reg.emplace<njin::transform>(z, njin::transform{.pos = {r.range(0.0f, 1280.0f), r.range(0.0f, 720.0f)}});
reg.emplace<zombie>(z);
}
}
void update(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
const njin::vec2 target = reg.get<njin::transform>(hero).pos;
const njin::f32 dt = njin::delta(ctx);
// Cả bầy đi về phía người chơi, rồi dựng lại index từ vị trí mới.
who.clear();
items.clear();
for (auto [e, tr, z] : reg.view<njin::transform, zombie>().each()) {
z.velocity = njin::normalize(target - tr.pos) * 40.0f;
tr.pos += z.velocity * dt;
who.push_back(e);
items.push_back({.pos = tr.pos, .radius = 6.0f});
}
// Quái rải đều trên một màn: lưới là lựa chọn nhanh nhất.
njin::spatial_build(index, {.kind = njin::spatial_grid}, items);
// Không chồng lên nhau: mỗi con chỉ bị 6 con chạm gần nhất đẩy ra.
njin::spatial_separate(index, push, 6);
for (std::size_t i = 0; i < who.size(); i++)
reg.get<njin::transform>(who[i]).pos += push[i];
// Tự ngắm: con gần người chơi nhất trong tầm 200.
njin::spatial_hit nearest[1];
aimed = njin::spatial_nearest(index, {.at = target, .radius = 200.0f}, nearest, 1) == 1 ? who[nearest[0].item]
: entt::null;
}
void draw(njin::context &ctx) {
const entt::registry &reg = njin::world(ctx);
for (auto [e, tr, z] : reg.view<const njin::transform, const zombie>().each())
njin::draw_circle(ctx, tr.pos, 6.0f, {0.4f, 0.7f, 0.3f, 1});
const njin::vec2 me = reg.get<njin::transform>(hero).pos;
if (reg.valid(aimed))
njin::draw_line(ctx, me, reg.get<njin::transform>(aimed).pos, 1.0f, {1, 0.3f, 0.2f, 1});
njin::draw_circle(ctx, me, 8.0f, {0.2f, 0.4f, 0.9f, 1});
}
void setup(njin::context &ctx) {
njin::ecs_register(ctx, njin::phase_startup, startup, "startup");
njin::ecs_register(ctx, njin::phase_update, update, "zombies");
}
} // namespace
int main() {
njin::context *ctx = njin::create({.title = "spatial", .width = 1280, .height = 720});
njin::mod_register(*ctx, {.name = "spatial", .setup = setup});
njin::run(*ctx);
}
void run(context &ctx)
Chạy vòng lặp chính cho đến khi cửa sổ đóng.
context * create(const config &cfg)
Mở cửa sổ và trả về context của engine.
void destroy(context *ctx)
Giải phóng mọi tài nguyên của engine và đóng cửa sổ.
void draw_circle(const context &ctx, vec2 center, f32 radius, rgba color)
Vẽ hình tròn đặc.
void draw_line(const context &ctx, vec2 a, vec2 b, f32 thickness, rgba color)
Vẽ đoạn thẳng.
void mod_register(context &ctx, const mod_desc &desc)
Chạy setup của module và đưa các system của nó vào lịch chạy.
@ phase_render
Mỗi frame, vẽ trong không gian thế giới.
Definition _mod.h:38
rng & random(context &ctx)
Bộ sinh số ngẫu nhiên dùng chung của engine.
void spatial_build(spatial_index &index, const spatial_desc &desc, const spatial_item *items, u32 count)
Dựng (lại) index từ count vật.
u32 spatial_separate(const spatial_index &index, std::vector< vec2 > &push, u32 k=6)
Tính độ đẩy để các vật chồng nhau tách ra: va chạm mềm cho đám đông.
u32 spatial_nearest(const spatial_index &index, const spatial_query &q, spatial_hit *out, u32 k)
Tìm tối đa k vật gần q.at nhất thỏa q, gần nhất trước.
@ spatial_grid
Lưới đều.
Definition njin_spatial.h:17
Bộ sinh số ngẫu nhiên PCG32: nhanh, chất lượng tốt, và lặp lại được: cùng hạt giống thì cùng dãy số.
Definition _random.h:13
f32 range(f32 lo, f32 hi)
Số thực trong [lo, hi).
Definition _random.h:50
Một vật tìm thấy.
Definition njin_spatial.h:107
Index không gian cho hàng nghìn hình tròn: tìm k vật gần nhất và đẩy các vật chồng nhau ra,...
Definition njin_spatial.h:58
vec2 pos
Vị trí trong thế giới.
Definition _comps.h:15

Vật fixed không bị đẩy, vật khác phải đi vòng qua nó: người đang ngồi, lính đang đứng gác. Vật cùng njin::spatial_item::group không đẩy nhau, để một nhóm đứng sát nhau được. Mỗi vật chỉ cộng lực đẩy của k vật chạm gần nhất, nên giữa một cụm dày nó không bị hất văng; phần chồng còn lại tan dần qua vài frame. Cụm càng dày thì mỗi lần hỏi càng phải xét nhiều vật, nên vẫn chậm hơn khi vật rải đều.

njin::spatial_desc::kind chọn cấu trúc bên dưới. Hai loại trả cùng kết quả, chỉ khác tốc độ, nên đổi qua lại để đo trong game của bạn:

Loại Nhanh hơn khi
njin::spatial_grid (mặc định) Vật cùng cỡ, rải khá đều trong một màn: bầy quái, đám đông, đạn. Dựng nhanh gấp 3 đến 4 lần quadtree.
njin::spatial_quadtree Map rất rộng mà vật dồn thành vài cụm, và hỏi với bán kính lớn.

Đo trên game crowd (bán kính 5, rải đều): với 50 000 người, cả bước mô phỏng kể cả dựng index và đẩy nhau hết 13,9 ms với lưới, 26,3 ms với quadtree. Trên một map 100 000 x 65 000 có 20 cụm, 5000 lần tìm 8 vật gần nhất trong bán kính 180 hết 10,7 ms với lưới, 7,2 ms với quadtree.

Dò lỗi

njin::collision_set_debug() vẽ khung mọi collider hộp và tròn: xanh lá là vật cản, vàng là trigger, xám là đang tắt.