09. Hoạt ảnh nhân vật (Character animation)


Trong bài học này, chúng ta sẽ sử dụng công cụ diễn hoạt có sẵn của Godot là AnimationPlayer để tạo hiệu ứng nhún nhảy (floating/flapping) sinh động cho nhân vật người chơi và quái vật. Bạn sẽ học cách thiết kế animation trực quan trên giao diện và điều khiển tốc độ diễn hoạt bằng mã nguồn.


Làm quen với bảng Animation Editor

Godot tích hợp sẵn công cụ diễn hoạt rất mạnh mẽ ngay trong trình soạn thảo:

  1. Mở scene player.tscn.
  2. Chọn node gốc Player, nhấn Ctrl + A thêm một node con AnimationPlayer.

  1. Bảng điều khiển Animation sẽ tự động xuất hiện ở thanh panel phía dưới màn hình.
  2. Nhấp vào menu Animation trên thanh công cụ của bảng này -> chọn New.

  1. Đặt tên hoạt ảnh là float (nhún nhảy).

Thiết lập lặp lại và tự động phát:

  • Bật biểu tượng mũi tên lặp lại (Animation looping) để hoạt ảnh tự lặp vô tận.
  • Bật biểu tượng chữ A có hình chữ nhật (Autoplay on Load) để hoạt ảnh tự động chạy ngay khi game mở.

  • Đặt thời lượng hoạt ảnh (Animation length) ở góc trên bên phải thành 1.2 giây.


Thiết kế hoạt ảnh "float"

Chúng ta sẽ tạo các điểm mốc thời gian (Keyframes) cho hai thuộc tính: Position (Vị trí) và Rotation (Góc xoay) của node Character (chứa mô hình 3D):

📌 Lưu ý
**Tại sao lại diễn hoạt trên node `Character` thay vì node `Player`?** Khi `AnimationPlayer` chạy, nó sẽ ghi đè giá trị vị trí của node mỗi khung hình. Nếu bạn animate trực tiếp trên `Player`, các lệnh vật lý di chuyển trong script sẽ bị vô hiệu hóa! Nhờ cấu trúc phân tầng `Player` -> `Pivot` -> `Character`, chúng ta có thể animate `Character` thoải mái mà `Player` vẫn tự do di chuyển trên sân đấu.

Các bước đặt Keyframe:

  1. Chọn node Character trong cây scene.
  2. Trong Inspector, mở rộng mục Transform. Bạn sẽ thấy các biểu tượng chiếc chìa khóa nhỏ bên cạnh Position và Rotation.

  1. Nhấp vào chìa khóa bên cạnh PositionRotation để tạo keyframe đầu tiên tại 0 giây (nếu có hộp thoại hỏi tạo RESET track, hãy bấm Create).

  1. Kéo biểu tượng hình thoi của Position đến mốc 0.3 giây và của Rotation đến mốc 0.1 giây.

  1. Di chuyển con trỏ thời gian đến mốc 0.5 giây:

  • Đổi Position -> Y thành 0.65 mét.
  • Đổi Rotation -> X thành 8 độ.
  • Bấm vào biểu tượng chìa khóa cạnh cả hai thuộc tính để chèn keyframe.
  • Kéo keyframe Position vừa tạo dời sang mốc 0.7 giây trên dòng thời gian. 6. Di chuyển con trỏ thời gian đến cuối hoạt ảnh tại 1.2 giây:
  • Đổi Position -> Y thành 0.35 mét.
  • Đổi Rotation -> X thành -9 độ.
  • Bấm chìa khóa để chèn keyframe cho cả hai.

Nhấn nút Play (hoặc phím tắt Shift + D) để xem trước chuyển động.

Tinh chỉnh đường cong chuyển động (Easing Curves)

Chuyển động mặc định là chuyển động thẳng đều (linear) nên trông khá cơ học và đơ cứng. Chúng ta sẽ chỉnh đường cong chuyển tiếp: 1. Dùng chuột quét chọn 2 keyframe đầu tiên trên dòng thời gian.

  1. Trong Inspector, tìm mục Easing:

kéo đường cong cong sang trái (Ease-out) để chuyển động bật nhanh lúc đầu và hãm chậm dần.

  1. Chỉnh Ease-out tương tự cho keyframe thứ hai của Rotation, và kéo Ease-in sang phải cho keyframe thứ hai của Position. Chuyển động nhún nhảy lúc này sẽ cực kỳ mềm mại và đàn hồi.


Điều khiển hoạt ảnh bằng mã nguồn trong player.gd

Chúng ta muốn khi người chơi đứng yên thì nhún nhảy thong thả, còn khi chạy thì đập cánh nhanh gấp 4 lần. Đồng thời, khi nhảy lên không trung, nhân vật sẽ hơi chúi người về phía trước theo quỹ đạo hình vòng cung.

Mở script player.gd và cập nhật hàm _physics_process():

    # Tăng tốc độ animation khi di chuyển
    if direction != Vector3.ZERO:
        direction = direction.normalized()
        $Pivot.basis = Basis.looking_at(direction)
        $AnimationPlayer.speed_scale = 4
    else:
        $AnimationPlayer.speed_scale = 1

    # ... (các đoạn code tính vận tốc và va chạm giữ nguyên) ...

    # Nghiêng người theo góc nảy khi nhảy (đặt ở cuối hàm)
    $Pivot.rotation.x = PI / 6 * velocity.y / jump_impulse

Tái sử dụng hoạt ảnh cho quái vật (Mob)

Vì cả PlayerMob đều có cùng cấu trúc phân tầng (Pivot -> Character), chúng ta có thể sao chép nguyên vẹn animation float sang cho Mob:

  1. Trong scene player.tscn, chọn node AnimationPlayer.
  2. Trên thanh menu Animation, chọn Manage Animations... -> nhấp vào biểu tượng Copy animation to clipboard (hai hình vuông nhỏ) bên cạnh animation float. Nhấn OK.
  3. Mở scene mob.tscn. Thêm một node con AnimationPlayer vào Mob.
  4. Trong Animation menu, chọn Manage Animations... -> chọn New Library (để trống tên thư viện và bấm OK) -> nhấn vào biểu tượng Paste (bảng kẹp giấy). Nhấn OK.
  5. Đảm bảo bật cả biểu tượng AutoplayLoop cho animation trong mob.tscn.

Đồng bộ tốc độ vỗ cánh với tốc độ bay của Quái vật

Mỗi con quái vật có một tốc độ ngẫu nhiên khác nhau. Những con bay nhanh sẽ vỗ cánh dồn dập hơn những con bay chậm.

Mở script mob.gd, thêm dòng sau vào cuối hàm initialize():

func initialize(start_position, player_position):
    # ... (code trước đó giữ nguyên) ...

    # Tốc độ animation tỷ lệ thuận với tốc độ di chuyển
    $AnimationPlayer.speed_scale = random_speed / min_speed

Mã nguồn hoàn chỉnh của Player (player.gd)

extends CharacterBody3D

signal hit

@export var speed = 14
@export var fall_acceleration = 75
@export var jump_impulse = 20
@export var bounce_impulse = 16

var target_velocity = Vector3.ZERO


func _physics_process(delta):
    var direction = Vector3.ZERO

    if Input.is_action_pressed("move_right"):
        direction.x += 1
    if Input.is_action_pressed("move_left"):
        direction.x -= 1
    if Input.is_action_pressed("move_back"):
        direction.z += 1
    if Input.is_action_pressed("move_forward"):
        direction.z -= 1

    if direction != Vector3.ZERO:
        direction = direction.normalized()
        $Pivot.basis = Basis.looking_at(direction)
        $AnimationPlayer.speed_scale = 4
    else:
        $AnimationPlayer.speed_scale = 1

    # Vận tốc mặt đất
    target_velocity.x = direction.x * speed
    target_velocity.z = direction.z * speed

    # Trọng lực
    if not is_on_floor():
        target_velocity.y = target_velocity.y - (fall_acceleration * delta)

    # Nhảy
    if is_on_floor() and Input.is_action_just_pressed("jump"):
        target_velocity.y = jump_impulse

    # Dẫm bẹp quái vật
    for index in range(get_slide_collision_count()):
        var collision = get_slide_collision(index)

        if collision.get_collider() == null:
            continue

        if collision.get_collider().is_in_group("mob"):
            var mob = collision.get_collider()
            if Vector3.UP.dot(collision.get_normal()) > 0.1:
                mob.squash()
                target_velocity.y = bounce_impulse
                break

    velocity = target_velocity
    move_and_slide()

    # Tạo độ nghiêng khi nhảy
    $Pivot.rotation.x = PI / 6 * velocity.y / jump_impulse


func die():
    hit.emit()
    queue_free()


func _on_mob_detector_body_entered(_body):
    die()

Mã nguồn hoàn chỉnh của Mob (mob.gd)

extends CharacterBody3D

signal squashed

@export var min_speed = 10
@export var max_speed = 18


func _physics_process(_delta):
    move_and_slide()


func initialize(start_position, player_position):
    look_at_from_position(start_position, player_position, Vector3.UP)
    rotate_y(randf_range(-PI / 4, PI / 4))

    var random_speed = randi_range(min_speed, max_speed)
    velocity = Vector3.FORWARD * random_speed
    velocity = velocity.rotated(Vector3.UP, rotation.y)

    # Đồng bộ tốc độ vỗ cánh
    $AnimationPlayer.speed_scale = random_speed / min_speed


func squash():
    squashed.emit()
    queue_free()


func _on_visible_on_screen_notifier_3d_screen_exited():
    queue_free()

Chúc mừng bạn đã hoàn thành game 3D đầu tiên!

Bấm F5 để thưởng thức thành quả: - Các nhân vật nhấp nhô sống động trên sân đấu 3D. - Nhân vật nghiêng người uyển chuyển mỗi khi bật nhảy và vỗ cánh nhanh thoăn thoắt khi chạy nước rút. - Toàn bộ cơ chế nhảy, đè bẹp kẻ thù, âm thanh nền, tính điểm và chơi lại đều vận hành trơn tru!