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
Tilemap

Tilemap là lưới ô vuông vẽ từ một tileset (một ảnh gồm các ô cùng kích thước). Dùng cho bản đồ platformer, RPG, thế giới đào xới.

#include <njin.h>
namespace {
constexpr njin::vec2 player_size{12, 14};
constexpr njin::f32 gravity = 900.0f;
constexpr njin::f32 jump_speed = 330.0f;
entt::entity level = entt::null;
njin::vec2 player_pos{40, 0};
njin::vec2 player_vel{};
bool on_ground = false;
void build(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
map.tileset = njin::texture_load(ctx, "assets/tiles.png");
map.tile_size = {16, 16};
// Không cần khai báo kích thước: đặt ô ở đâu, chunk ở đó được tạo.
for (int x = -20; x < 400; x++)
njin::tilemap_set(map, x, 10, 1); // mặt đất dài 420 ô, trải qua 14 chunk
for (int y = 6; y < 10; y++)
njin::tilemap_set(map, 15, y, 2); // một bức tường
njin::tilemap_set(map, 8, 7, 3); // một bục nhỏ
level = reg.create();
reg.emplace<njin::transform>(level, njin::transform{.pos = {0, 0}});
reg.emplace<njin::tilemap>(level, std::move(map));
}
// Vật lý platformer trong phase cố định: kết quả không phụ thuộc FPS.
void physics(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
const auto &map = reg.get<njin::tilemap>(level);
const njin::vec2 origin = reg.get<njin::transform>(level).pos;
const njin::f32 dt = njin::delta(ctx); // đúng một nhịp cố định
njin::f32 dir = 0;
dir -= 1;
dir += 1;
player_vel.x = dir * 140.0f;
player_vel.y += gravity * dt;
if (on_ground && njin::key_pressed(ctx, njin::key_space))
player_vel.y = -jump_speed;
// Đi ngang trước rồi dọc: trượt dọc tường thay vì dính vào.
map, origin, njin::rect{player_pos, player_size}, player_vel * dt);
player_pos = mv.pos;
on_ground = mv.hit_y && player_vel.y > 0;
if (mv.hit_y)
player_vel.y = 0;
}
// Đào ô bằng chuột: tilemap_set đánh dấu chunk cần vẽ lại.
void dig(njin::context &ctx) {
return;
entt::registry &reg = njin::world(ctx);
auto &map = reg.get<njin::tilemap>(level);
const njin::vec2 origin = reg.get<njin::transform>(level).pos;
const njin::vec2 world_pos = njin::scr2w(ctx, njin::mouse_pos(ctx));
const njin::cell c = njin::tilemap_cell_at(map, origin, world_pos);
njin::tilemap_set(map, c.x, c.y, -1);
}
void draw_player(njin::context &ctx) {
njin::draw_rect(ctx, njin::rect{player_pos, player_size}, njin::colors::yellow);
}
void setup(njin::context &ctx) {
}
} // namespace
njin::mod_desc platformer_module() { return {.name = "platformer", .setup = setup}; }
vec2 scr2w(const context &ctx, vec2 pos)
Đổi một điểm từ pixel màn hình sang thế giới, qua camera_active().
void draw_rect(const context &ctx, rect r, rgba color)
Vẽ hình chữ nhật đặc.
bool mouse_pressed(const context &ctx, mouse_button button)
Đúng ở frame mà nút chuột vừa được nhấn xuống (chỉ một frame).
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ữ.
vec2 mouse_pos(const context &ctx)
Vị trí con trỏ chuột, tính bằng pixel màn hình.
@ mouse_left
Chuột trái.
Definition _types.h:475
@ key_space
Phím cách.
Definition _types.h:411
@ key_right
Mũi tên phải.
Definition _types.h:401
@ key_left
Mũi tên trái.
Definition _types.h:400
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::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_render
Mỗi frame, vẽ trong không gian thế giới.
Definition _mod.h:38
@ 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
texture_handle texture_load(context &ctx, const char *path)
Nạp một texture từ file ảnh.
move_result tilemap_move(const tilemap &map, vec2 origin, rect box, vec2 delta)
Di chuyển một hình chữ nhật trong tilemap, dừng lại khi đụng ô không trống.
Definition _tilemap.h:464
void tilemap_set(tilemap &map, i32 x, i32 y, i32 id)
Đặt ô tại cột x, hàng y.
Definition _tilemap.h:265
cell tilemap_cell_at(const tilemap &map, vec2 origin, vec2 world_pos)
Ô chứa một điểm trong thế giới.
Definition _tilemap.h:408
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.
float f32
Số thực 32 bit.
Definition _types.h:28
constexpr rgba yellow
Vàng.
Definition _math.h:239
Vị trí một ô trong lưới. Có thể âm.
Definition _tilemap.h:43
i32 x
Cột.
Definition _tilemap.h:44
i32 y
Hàng.
Definition _tilemap.h:45
Mô tả một module: một nhóm system có tên.
Definition _mod.h:67
Kết quả của tilemap_move().
Definition _tilemap.h:215
bool hit_y
Bị chặn theo trục dọc.
Definition _tilemap.h:218
vec2 pos
Vị trí góc trên trái mới của vật.
Definition _tilemap.h:216
Hình chữ nhật thẳng trục: pos là góc trên trái, size là rộng và cao.
Definition _math.h:13
Lưới ô vuông không giới hạn kích thước, vẽ từ một tileset, chia thành chunk.
Definition _tilemap.h:128
texture_handle tileset
Ảnh tileset.
Definition _tilemap.h:129
vec2 tile_size
Kích thước một ô, tính bằng pixel.
Definition _tilemap.h:130
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

Tạo tilemap

Gắn njin::transform và njin::tilemap vào một entity. transform.pos là góc trên trái của ô (0, 0) trong thế giới.

  • tileset, tile_size: ảnh và kích thước một ô.
  • Mỗi ô giữ số thứ tự ô trong tileset (từ 0, theo hàng), hoặc -1 là ô trống.
  • Đổi ô bằng njin::tilemap_set(), đọc bằng njin::tilemap_get().
  • layer, tint, visible như sprite.

Tilemap không có kích thước cố định và tọa độ ô có thể âm: đặt ô ở đâu thì bản đồ mở rộng tới đó.

Viết bản đồ bằng chữ

Không muốn dùng Tiled hay LDtk? Viết bản đồ như ngày xưa: mỗi ký tự là một ô. njin::tilemap_from_text() đọc một chuỗi nhiều dòng (viết thẳng trong code, hoặc đọc từ một file văn bản bằng njin::file_read()), còn njin::tilemap_from_rows() nhận từng hàng riêng cho bản đồ nhỏ.

#include <njin.h>
namespace {
using namespace njin;
void startup(context &ctx) {
const axis_handle move_x = axis_define(ctx, "move_x", {{key_left, key_right}, {key_a, key_d}}, {pad_axis_left_x});
const axis_handle move_y = axis_define(ctx, "move_y", {{key_up, key_down}, {key_w, key_s}}, {pad_axis_left_y});
const action_handle dash = action_define(ctx, "dash", {key_space, pad_face_down});
entt::registry &reg = world(ctx);
// 1. Bản đồ viết bằng chữ, mỗi ký tự một ô: # là tường đá (ô 7), . là cỏ (ô 0), P là chỗ nhân vật
// xuất hiện. P vừa đặt ô cỏ 0 bên dưới, vừa được báo lại (tham số thứ ba là true).
tilemap map;
map.tileset = texture_load(ctx, "assets/tiles.png");
tilemap_set_shape(map, 0, tile_none); // cỏ chỉ để nhìn, đi qua được
const std::vector<tile_marker> markers = tilemap_from_text(map, R"(
####################
#..................#
#..P...............#
#..................#
#..................#
#.....####.........#
#..................#
#..................#
#..................#
#..................#
#..................#
####################
)", {{'#', 7}, {'.', 0}, {'P', 0, true}});
vec2 start{};
for (const tile_marker &m : markers)
if (m.symbol == 'P') {
const rect r = tilemap_cell_rect(map, {0.0f, 0.0f}, m.at.x, m.at.y); // ô -> hình chữ nhật trong thế giới
start = {r.pos.x + r.size.x * 0.5f, r.pos.y + r.size.y}; // chân ở đáy ô
}
const entt::entity level = reg.create();
reg.emplace<transform>(level);
reg.emplace<tilemap>(level, std::move(map));
reg.emplace<collider>(level, collider{.shape = collider_tiles}); // đá thành vật cản
// 2. Nhân vật ở đúng chỗ chữ P.
const entt::entity player = reg.create();
reg.emplace<transform>(player, transform{.pos = start});
reg.emplace<collider>(player, collider{.size = {10.0f, 8.0f}, .offset = {0.0f, -4.0f}});
reg.emplace<topdown_body>(player, topdown_body{.dash_speed = 230.0f});
reg.emplace<topdown_input_map>(player, topdown_input_map{move_x, move_y, dash});
// 3. Camera bám nhân vật, không lộ ra ngoài bản đồ (20 x 12 ô, mỗi ô 16 pixel).
const rect bounds{{0.0f, 0.0f}, {320.0f, 192.0f}};
reg.emplace<camera_follow>(camera_spawn(ctx, 3.0f), camera_follow{.target = player, .bounds = bounds});
}
void draw_player(context &ctx) {
world(ctx).view<transform, collider, topdown_body>().each(
[&](const transform &tr, const collider &col, const topdown_body &) {
});
}
void setup(context &ctx) {
ecs_register(ctx, phase_startup, startup);
ecs_register(ctx, phase_render, draw_player);
}
} // namespace
int main() {
context *ctx = create({.title = "Bản đồ bằng chữ", .width = 960, .height = 540, .target_fps = 60});
mod_register(*ctx, {.name = "game", .setup = setup});
run(*ctx);
destroy(ctx);
}
entt::entity camera_spawn(context &ctx, f32 zoom=1.0f, vec2 pos={})
Tạo một entity camera đang dùng (transform, njin::camera_2d, njin::camera_on) nhìn vào pos,...
@ 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
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ổ.
const char * tr(const context &ctx, const char *key)
Chuỗi đã dịch của key trong ngôn ngữ đang dùng.
action_handle action_define(context &ctx, const char *name, std::initializer_list< binding > sources)
Đăng ký một action và gắn mọi nguồn của nó trong một lời gọi.
Definition njin_bindings.h:102
axis_handle axis_define(context &ctx, const char *name, std::initializer_list< axis_keys > keys, std::initializer_list< gamepad_axis > pads={})
Đăng ký một axis và gắn mọi cặp phím và trục tay cầm của nó trong một lời gọi.
Definition njin_bindings.h:129
@ pad_face_down
Nút mặt dưới (A trên Xbox, Chéo trên PlayStation).
Definition _types.h:497
@ pad_axis_left_x
Cần trái, trục ngang (-1 là trái, 1 là phải).
Definition _types.h:513
@ pad_axis_left_y
Cần trái, trục dọc (-1 là lên, 1 là xuống).
Definition _types.h:514
@ key_up
Mũi tên lên.
Definition _types.h:398
@ 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_down
Mũi tên xuống.
Definition _types.h:399
constexpr rect rect_from_center(vec2 center, vec2 size)
Tạo hình chữ nhật từ tâm và kích thước.
Definition _math.h:228
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.
void texture_set_filter(context &ctx, texture_handle handle, texture_filter filter)
Đổi cách lấy mẫu của texture.
@ filter_nearest
Giữ nguyên từng pixel, sắc cạnh. Dùng cho pixel art.
Definition njin_draw.h:198
std::vector< tile_marker > tilemap_from_text(tilemap &map, std::string_view text, std::initializer_list< tile_key > legend={}, cell origin={})
Dựng bản đồ từ một chuỗi nhiều dòng, mỗi ký tự là một ô: cách viết bản đồ "kiểu truyền thống",...
Definition _tilemap.h:361
void tilemap_set_shape(tilemap &map, i32 id, tile_shape shape)
Đặt hình va chạm cho mọi ô có số thứ tự id trong tilemap.
Definition _tilemap.h:150
rect tilemap_cell_rect(const tilemap &map, vec2 origin, i32 x, i32 y)
Hình chữ nhật của một ô trong thế giới.
Definition _tilemap.h:420
@ tile_none
Không va chạm: chỉ để nhìn, dù nằm trong layer vật cản.
Definition _tilemap.h:57
Định danh của một action nhập liệu, tạo bởi action_register().
Definition _types.h:85
Định danh của một axis nhập liệu, tạo bởi axis_register().
Definition _types.h:93
Cho camera bám theo một entity: mượt, có vùng chết, nhìn trước theo hướng chạy, và không bao giờ lộ r...
Definition njin_camera.h:31
Hình va chạm của một entity.
Definition njin_collision.h:46
vec2 offset
Độ lệch của tâm so với transform.pos.
Definition njin_collision.h:50
vec2 size
Kích thước hình hộp.
Definition njin_collision.h:48
vec2 pos
Góc trên trái.
Definition _math.h:14
vec2 size
Chiều rộng (x) và chiều cao (y).
Definition _math.h:15
Vị trí của một ký tự mà bản đồ chữ chỉ dùng để đánh dấu: người chơi, kẻ địch, đồng xu.
Definition _tilemap.h:309
Nhân vật top-down: đi 8 hướng (hoặc mọi hướng với cần analog), trượt dọc tường, lướt.
Definition njin_body.h:97
Gắn action và axis vào một njin::topdown_body, như njin::platformer_input_map.
Definition njin_body.h:126

Tham số thứ ba là bảng ký tự (njin::tile_key): ký tự nào đặt ô số mấy. Ở ví dụ trên # là ô 7 (tường đá) và . là ô 0 (cỏ). Ký tự không có trong bảng thì có hai trường hợp:

  • , . và tab là ô trống;
  • mọi ký tự khác là điểm đánh dấu: hàm không đặt ô nào, mà trả về vị trí của nó trong một danh sách njin::tile_marker (theo thứ tự đọc, từ trên xuống và trái sang phải). Đây là chỗ để đặt điểm xuất hiện của người chơi (P), kẻ địch (E), đồng xu, cửa... Tự tạo entity ở đúng ô đó; njin::tilemap_cell_rect() đổi ô thành vị trí trong thế giới.

Một ký tự có thể vừa đặt ô vừa được báo lại: {'P', 0, true} đặt ô cỏ 0 dưới chân nhân vật và báo vị trí của P, để không có lỗ hổng dưới người chơi.

Quy tắc Chi tiết
Vị trí Dòng đầu là hàng 0, ký tự đầu là cột 0, cộng thêm tham số origin nếu có. Các hàng dài ngắn khác nhau được
Dòng đầu chuỗi Nếu chuỗi bắt đầu bằng xuống dòng thì xuống dòng đó bị bỏ, để viết R"( rồi xuống dòng mới đến hàng đầu. Dòng cuối kết thúc bằng xuống dòng không thêm hàng rỗng
Kết thúc dòng Windows \r\n được hiểu đúng, không có ký tự \r lạc
Chồng lớp Ô trống và điểm đánh dấu không đụng tới ô đang có, nên gọi nhiều lần cho nhiều lớp (nền, rồi vật trang trí). Muốn xóa một ô, ghi rõ trong bảng: {'x', -1}
Hình va chạm Ký tự trong bảng gọi njin::tilemap_set(), nên njin::tilemap_set_shape() và njin::tilemap_animate() áp dụng như bình thường
Ghi chú
Bản đồ chữ chỉ dựng ô và báo vị trí. Nó không tạo entity, không có thuộc tính hay đối tượng như Tiled/LDtk (xem Level từ Tiled và LDtk). Muốn có những thứ đó thì đọc danh sách njin::tile_marker rồi tạo entity, hoặc dùng njin::prefab_spawn() (Prefab và transform cha–con) cho từng ký tự. Muốn lưu bản đồ ra file thì ghi lại chính chuỗi chữ.

Chunking

Ô được lưu và vẽ theo chunk: khối 32 x 32 ô (njin::tile_chunk_size).

flowchart LR
  S["tilemap_set()"]:::api --> C[("chunk.version tăng")]:::data
  C --> B["post_update: bake lại<br/>chunk đó vào ảnh riêng"]:::update
  B --> D["render: mỗi chunk trong<br/>khung nhìn = 1 lệnh vẽ"]:::render

Lưu trữ thưa. Chỉ những chunk có ít nhất một ô mới tồn tại. Một bản đồ rộng hàng nghìn ô nhưng thưa vẫn nhẹ. Xóa hết ô của một chunk thì chunk bị xóa.

Vẽ sẵn (bake). Mỗi chunk được vẽ một lần vào một ảnh riêng. Mỗi frame, chunk chỉ tốn một lệnh vẽ thay vì 1024 lệnh cho từng ô. Chunk chỉ được vẽ lại khi có ô đổi: mỗi chunk có số version tăng lên mỗi lần njin::tilemap_set() đổi nó.

Chỉ vẽ những gì nhìn thấy. Chỉ chunk nằm trong khung nhìn của camera (njin::camera_bounds()) mới được bake và vẽ.

Giải phóng. Ảnh của chunk khuất khỏi màn hình khoảng 5 giây (300 frame) bị giải phóng, và được bake lại khi quay lại. Ảnh của chunk hay tilemap đã bị xóa được giải phóng ngay frame sau.

Đổi ô sau khi đã bake. Chunk vừa đổi trong frame này (ví dụ trong phase_render) sẽ được vẽ từng ô một lần cho đúng, rồi được bake lại ở frame sau. Không bao giờ hiện ảnh cũ.

Lưu ý
Luôn đổi ô bằng njin::tilemap_set(). Sửa thẳng tilemap::chunks thì engine không biết chunk nào cần vẽ lại.

Va chạm với tilemap

Mọi ô không trống đều là vật cản.

Hàm Việc làm
njin::tilemap_cell_at() Ô chứa một điểm trong thế giới
njin::tilemap_cell_rect() Hình chữ nhật của một ô trong thế giới
njin::tilemap_overlaps() Một hình chữ nhật có chạm ô nào không
njin::tilemap_move() Di chuyển một hình chữ nhật, dừng khi đụng ô

njin::tilemap_move() đi theo trục ngang trước rồi đến trục dọc, nên nhân vật trượt dọc tường thay vì dính vào. hit_y && delta.y > 0 nghĩa là đang đứng trên mặt đất.

Cần va chạm với cả tilemap lẫn các entity khác (thùng, cửa, quái) thì gắn collider collider_tiles lên tilemap và dùng njin::collision_move(), xem Va chạm.

Ghi chú
Mỗi lần gọi, mỗi trục nên dời không quá một ô. Vật đi quá nhanh có thể xuyên qua tường mỏng. Đặt vật lý trong phase_fixed_update để bước đi nhỏ và đều (xem Thời gian, fixed update, timer và tween).

Hình va chạm và animation của ô

Mỗi loại ô có thể có hình va chạm riêng (bục một chiều, dốc, không va chạm) và animation (nước, đuốc). Cả hai đặt theo số thứ tự ô, nên áp dụng cho mọi ô cùng loại:

njin::tilemap_set_shape(map, 3, njin::tile_one_way);
njin::tilemap_set_shape(map, 4, njin::tile_slope_r);
njin::tilemap_animate(map, 10, {10, 11, 12, 13}, 0.18f); // ô 10 chạy qua bốn frame

Bản đồ nạp từ Tiled và LDtk có sẵn cả hai (xem Level từ Tiled và LDtk). Ô có animation không bị bake vào ảnh chunk mà vẽ chồng lên mỗi frame, nên chunk tĩnh vẫn rẻ. collision_move() và collision_raycast() hiểu hình của ô; tilemap_move() thì coi mọi ô là vật cản. Xem Làm game platformer để dùng chúng.