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
Animation và máy trạng thái

njin::sprite_anim (xem Sprite và animation) đủ cho một dải frame chạy đều. Nhân vật thật cần hơn thế: mỗi frame một thời lượng riêng, nhiều animation có tên, và luật chuyển giữa chúng (đứng → chạy → nhảy → đánh). Phần này làm việc đó.

Ba khái niệm:

Khái niệm Là gì Tạo bằng
Sheet Ảnh + danh sách frame (vùng, thời lượng) + các clip có tên njin::anim_sheet_load() (Aseprite) hoặc njin::anim_sheet_grid()
Graph Máy trạng thái: các trạng thái (mỗi cái chạy một clip) và luật chuyển njin::anim_graph_create()
Animator Component trên entity: trạng thái hiện tại và tham số riêng reg.emplace<njin::animator>(e, {.graph = g})

Nhiều entity dùng chung một sheet và một graph; mỗi entity có animator riêng.

Ví dụ

#include <njin.h>
namespace {
// Nhân vật dùng sheet xuất từ Aseprite với 4 tag: idle, run, jump, attack.
entt::entity hero = entt::null;
bool grounded = true;
njin::f32 vel_y = 0.0f;
void spawn(njin::context &ctx) {
const njin::anim_sheet_handle sheet = njin::anim_sheet_load(ctx, "assets/hero.json");
hero_anim = njin::anim_graph_create(ctx, {
.sheet = sheet,
.states = {{.name = "idle", .clip = "idle"},
{.name = "run", .clip = "run"},
{.name = "jump", .clip = "jump", .repeat = 1}, // chạy một lượt rồi đứng ở frame cuối
{.name = "attack", .clip = "attack", .repeat = 1}},
// Xét từ trên xuống, dùng cái đầu tiên thỏa mãn.
.transitions = {
{.to = "attack", .when = {{"attack", njin::anim_trigger}}}, // từ mọi trạng thái
{.from = "attack", .to = "idle", .after_finish = true}, // đánh xong thì về idle
{.from = "idle", .to = "jump", .when = {{"grounded", njin::anim_false}}},
{.from = "run", .to = "jump", .when = {{"grounded", njin::anim_false}}},
{.from = "jump", .to = "idle", .when = {{"grounded", njin::anim_true}}},
{.from = "idle", .to = "run", .when = {{"speed", njin::anim_gt, 10}}},
{.from = "run", .to = "idle", .when = {{"speed", njin::anim_le, 10}}},
}});
entt::registry &reg = njin::world(ctx);
hero = reg.create();
reg.emplace<njin::transform>(hero, njin::transform{.pos = {200, 300}, .scale = 3});
reg.emplace<njin::sprite>(hero, njin::sprite{.origin = {0.5f, 1.0f}});
reg.emplace<njin::animator>(hero, njin::animator{.graph = hero_anim});
}
void control(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
auto &tr = reg.get<njin::transform>(hero);
auto &anim = reg.get<njin::animator>(hero);
const njin::f32 dt = njin::delta(ctx);
njin::f32 dir = 0;
dir -= 1;
dir += 1;
if (grounded && njin::key_pressed(ctx, njin::key_space)) {
vel_y = -420.0f;
grounded = false;
}
vel_y += 1200.0f * dt;
tr.pos += njin::vec2{dir * 160.0f, vel_y} * dt;
if (tr.pos.y >= 300.0f) { // mặt đất
tr.pos.y = 300.0f;
vel_y = 0.0f;
grounded = true;
}
if (dir != 0)
reg.get<njin::sprite>(hero).flip_x = dir < 0;
// Game chỉ báo tình trạng; graph tự chọn animation.
njin::animator_set(ctx, anim, "speed", dir != 0 ? 160.0f : 0.0f);
njin::animator_set_bool(ctx, anim, "grounded", grounded);
njin::animator_trigger(ctx, anim, "attack");
// Khóa di chuyển khi đang đánh.
if (njin::animator_in(ctx, anim, "attack"))
tr.pos.x -= dir * 160.0f * dt;
}
void setup(njin::context &ctx) {
}
} // namespace
njin::mod_desc animation_module() { return {.name = "animation", .setup = setup}; }
void animator_set(const context &ctx, animator &anim, const char *param, f32 value)
Đặt một tham số số của graph, ví dụ tốc độ chạy.
anim_sheet_handle anim_sheet_load(context &ctx, const char *json_path)
Nạp sprite sheet và các clip từ file JSON do Aseprite xuất ra.
void animator_trigger(const context &ctx, animator &anim, const char *param)
Bật một trigger của graph cho frame này, ví dụ vừa bấm nút đánh.
void animator_set_bool(const context &ctx, animator &anim, const char *param, bool value)
Đặt một tham số bool của graph, ví dụ đang đứng trên đất.
bool animator_in(const context &ctx, const animator &anim, const char *name)
Đang ở trạng thái (có graph) hoặc clip (không graph) này không.
anim_graph_handle anim_graph_create(context &ctx, const anim_graph_desc &desc)
Tạo một máy trạng thái animation.
@ anim_le
Tham số nhỏ hơn hoặc bằng value.
Definition njin_anim.h:99
@ anim_false
Tham số bằng 0 (bool đang tắt).
Definition njin_anim.h:103
@ anim_gt
Tham số lớn hơn value.
Definition njin_anim.h:96
@ anim_trigger
Tham số được bật bằng animator_trigger() trong frame này.
Definition njin_anim.h:106
@ anim_true
Tham số khác 0 (bool đang bật).
Definition njin_anim.h:102
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_space
Phím cách.
Definition _types.h:411
@ key_d
Phím D.
Definition _types.h:347
@ key_a
Phím A.
Definition _types.h:344
@ key_j
Phím J.
Definition _types.h:353
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
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
Định danh của một máy trạng thái animation, tạo bởi anim_graph_create().
Definition _types.h:330
Định danh của một sprite sheet có animation, tạo bởi anim_sheet_load() hoặc anim_sheet_grid().
Definition _types.h:322
Chạy animation từ một sheet có clip, cho entity có sprite.
Definition njin_anim.h:187
Mô tả một module: một nhóm system có tên.
Definition _mod.h:67
Ảnh vẽ tại vị trí của entity.
Definition _comps.h:44
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

Xuất từ Aseprite

Trong Aseprite, đặt tag cho từng đoạn animation (idle, run...), rồi File > Export Sprite Sheet:

  • tab Output: bật JSON Data, chọn Array hoặc Hash, bật Tags;
  • tab Borders: tắt Trim (frame bị cắt sẽ lệch vị trí; engine cảnh báo nếu gặp).

Hoặc bằng dòng lệnh:

aseprite -b hero.aseprite --sheet hero.png --data hero.json --list-tags --format json-array

njin::anim_sheet_load() đọc file JSON, nạp ảnh nằm cạnh nó, và biến mỗi tag thành một clip cùng tên. Những gì lấy từ Aseprite:

Trong Aseprite Trong njin
Thời lượng từng frame Giữ nguyên, tính bằng mili giây
Hướng của tag: Forward, Reverse, Ping-pong, Ping-pong Reverse njin::anim_direction
Repeat của tag (∞ hoặc số lần) repeat của clip: 0 là lặp mãi

Không có tag nào thì có một clip tên "default" gồm mọi frame.

Sheet chia lưới đều (không dùng Aseprite) thì tạo bằng njin::anim_sheet_grid() rồi thêm clip bằng njin::anim_sheet_add_clip().

Máy trạng thái

Mỗi frame, trong phase_post_update, engine:

  1. xét các chuyển trạng thái theo thứ tự khai báo, dùng cái đầu tiên thỏa mãn (tối đa một lần mỗi frame);
  2. tắt mọi trigger;
  3. cho clip chạy theo delta() nhân animator.speed và speed của trạng thái;
  4. ghi sprite.texture và sprite.source.

Một chuyển trạng thái thỏa mãn khi:

  • from đúng trạng thái hiện tại, hoặc from là null (từ mọi trạng thái, trừ chính to);
  • mọi điều kiện trong when đúng;
  • nếu có after_finish: clip hiện tại đã chạy hết ít nhất một lượt.

Vì xét theo thứ tự, hãy đặt chuyển quan trọng lên trước: bị đánh, chết, rồi mới đến đánh, nhảy, chạy.

Tham số

Điều kiện so sánh tham số với một giá trị. Tham số là số thực có tên, tự được tạo từ các điều kiện (tối đa njin::anim_max_params cái). Game đặt chúng mỗi frame:

Hàm Dùng cho So sánh bằng
njin::animator_set() Số: tốc độ, máu anim_gt, anim_lt, anim_ge, anim_le, anim_eq, anim_ne
njin::animator_set_bool() Bật/tắt: đang đứng trên đất anim_true, anim_false
njin::animator_trigger() Sự kiện một lần: bấm nút đánh anim_trigger

Trigger chỉ sống trong frame được bật. Nó bị tiêu thụ khi làm chuyển trạng thái, và tự tắt cuối frame nếu không dùng đến, nên một cú bấm lúc đang nhảy không bị "để dành" rồi bất ngờ bật ra sau đó.

Không dùng graph

Chỉ cần chạy clip theo tên thì gán sheet thay vì graph và gọi njin::animator_play():

reg.emplace<njin::animator>(coin, njin::animator{.sheet = coin_sheet});
njin::animator_play(ctx, reg.get<njin::animator>(coin), "spin");
bool animator_play(const context &ctx, animator &anim, const char *name, bool restart=false)
Chuyển ngay sang một clip (không graph) hoặc một trạng thái (có graph).

Giống njin::anim_play(), gọi mỗi frame được: đang chạy đúng clip đó thì không làm gì. Với graph, njin::animator_play() nhảy thẳng tới một trạng thái (ví dụ khi hồi sinh).

Đọc trạng thái

Trường / hàm Ý nghĩa
njin::animator_in() Đang ở trạng thái (hoặc clip) có tên này không
njin::animator_current() Tên trạng thái (hoặc clip) hiện tại
animator.finished Clip không lặp đã chạy hết
animator.state_time Đã ở trạng thái hiện tại bao lâu
animator.frame Frame đang hiện trong sheet, để canh hitbox theo frame
njin::anim_clip_duration() Tổng thời lượng một lượt của clip